From 286bbb07a54958158941c5fe8e2e5afc61e43c5e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:37:09 +0200 Subject: [PATCH 01/52] docs: add full Foldkit/Effect audit report Co-Authored-By: Claude Opus 5.5 --- docs/foldkit-review/2026-10-full-audit.md | 122 ++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/foldkit-review/2026-10-full-audit.md diff --git a/docs/foldkit-review/2026-10-full-audit.md b/docs/foldkit-review/2026-10-full-audit.md new file mode 100644 index 000000000..1e775a138 --- /dev/null +++ b/docs/foldkit-review/2026-10-full-audit.md @@ -0,0 +1,122 @@ +# Full audit — Foldkit practices, Effect errors, UI state (2026-10-15) + +Eight read-only review agents graded `apps/web-foldkit` against Foldkit 0.167.0's own audit spec +(`skills/generate-program/{architecture,conventions,checklist,blindSpots}.md`), the foldkit.dev docs and +exemplars (typing-game, examples/auth, form, job-application), plus the repo's Effect error rules. A ninth +slice covered `apps/backend` error handling. Paths are relative to `apps/web-foldkit/src/` unless prefixed. + +Lint baseline: Foldkit plugin is active; 9 errors (2× no-switch-on-message-tag, 2× no-module-level-mutable-state, +2× command-define-pascal-const, 2× command-binding-matches-name, 1× no-hardcoded-route-strings) + 24 warnings. + +--- + +## P0 — User-visible bugs (fix first, small and independent) + +| # | Where | Bug | Fix | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | `app/auth.ts:13-38` | API slow/unreachable at boot → `orElseSucceed("SignedOut")` → user bounced to sign-in, never rechecked | `FailedCheckSession{reason}`; keep `Loading`/`Unreachable`, retry from update | +| 2 | `platform/desktop-auth.ts:64-67,149-156` | `Effect.promise` around a failing runtime → defect → crash view on desktop boot; failed runtime cached forever | `tryPromise` + tagged error; don't cache failures | +| 3 | `app/model.ts:25-28,52-57` | Org switch: pages init with new `orgSlug` but old `organization`/`member` (general settings seeds old name, old-org subscription, old role in `can`) | `OrgState = Loading{slug} \| Loaded{slug,…}`; only expose when slug matches | +| 4 | `mount/message-list.ts:235`, `channel/page.ts:220` | New message while Files tab open → `ApplyScroll` on missing element returns 0 → pages through entire history | `SkippedApplyScroll`; skip `loadWhenNearEdge` when tab ≠ messages | +| 5 | `page/chat/thread-panel.ts:120`, `channel/view.ts:169` | Images in thread replies can't open; hover toolbar sticks | Look up viewer in thread rows too / record source | +| 6 | `page/chat/banners.ts:45` | "Join #channel" button has no handler | `ClickedJoinChannel` + `JoinChannel` Command | +| 7 | `page/select-organization/update.ts:33` | "Create a new one" → `/onboarding` → redirected back (loop) | Navigate to `OnboardingSetupOrganization` | +| 8 | `auth/flows.ts:18-27` | `PasskeyBrowserInputRejected` matches `endsWith("Rejected")` → "sign-in expired" | `AuthFlowError{reason}` built via `catchTags` | +| 9 | `core/lib/error-messages.ts:209-223,469,517-558` | Still v3 shapes: `reason === "Transport"` never matches v4; no `SchemaError`/`HttpClientError` keys; retry policy says network errors aren't retryable (message sends never retried); raw error/defect text shown to users | Rewrite as `Match.tagsExhaustive` over a typed union; split retry policy; never display unknown messages | +| 10 | `overlay/modal/delete-message.ts:34` | Delete failure `ignoreCause`d — optimistic rollback with no feedback | `FailedDeleteMessage` | +| 11 | settings: `chat-sync-connection/update.ts:281,298`, `invitations/update.ts:26`, `connect-invites/update.ts:184`, `connect/share-modal.ts:173` | Load failures render as "not found"/empty | `AsyncData` with Failure + retry | +| 12 | `page/profile/model.ts:18`, `page/join/model.ts:11` | `null` = loading and not-found → "User not found"/"Workspace Not Found" flashes; network error looks like bad link | `AsyncData` / `Loading\|Found\|NotFound\|Failed` | +| 13 | `my-settings/profile/form.ts:9` | Shows "firstName must be non-empty" | `fieldValidation` rules with real copy | +| 14 | `settings/custom-emojis/update.ts:73-121` | Every create failure → "name may already be taken" | `catchTags` | +| 15 | `channel-settings/overview/update.ts:102` | Save stays enabled after successful save (defaults never rebased) | Derive dirty from `values !== defaults`, rebase on success | +| 16 | `composer/composer.ts:168`, `composer/update.ts:72` | Files-tab round-trip loses draft text while "Editing message" survives; submit does nothing | Model owns markdown; pass as Mount arg | +| 17 | `composer/upload.ts:72-99` | XHR not interruptible; switching channel leaves upload running and row never marked failed | `Effect.callback` + abort finalizer + `onInterrupt` | +| 18 | `emoji-picker/picker.ts:22,175`, `gif-picker/picker.ts:233` | Emoji load failure = permanent spinner; GIF network error = "No GIFs found" | `AsyncData` | +| 19 | `overlay/modal.ts:100`, `modal/contract.ts:96` | Late result from a closed modal drops its toast; reopened modal closed by old instance's success | Instance nonce on slot + result | +| 20 | `overlay/command-palette/search-update.ts:116-163` | `if` chain whose fall-through clears recent searches | `Message.match` exhaustive | +| 21 | `ui/switch.ts`, `ui/checkbox.ts`, `ui/tab-strip.ts`, `page/chat/tab-bar.ts` | `data-rac` always emitted → no focus ring on 5 switches/2 checkboxes; tab hover never applies | Emit `data-rac` only with interaction wiring | +| 22 | `platform/presence/update.ts:99-109` | Failed presence send leaves status stuck (sent written before success) | Set `sent` on success | +| 23 | `gallery/boot.ts:4` | Whole gallery (~55 entries, ~2.5k lines kit code) eagerly bundled in prod | Lazy glob / env gate | +| 24 | `page/chat/channel/write.ts:265,279` | Thread create/name results ignore their thread id (stale results) | Compare ids | + +## P1 — Effect error architecture (frontend) + +The single biggest theme: **failures are caught wholesale and turned into strings/toasts in Commands.** + +- ~40 `Effect.catch(() => FailedX())`, ~26 `catchCause(...)`, `String(error)` `reason` payloads nobody reads, + `_tag`-string sniffing (`composer/draft.ts:208`, `install-bot-by-id.ts:51`, `invite/command.ts`, + `desktop-handoff.ts`, `detail/command.ts:69`), `Record` in `data/actions.ts:43`. +- Three Failure Message shapes coexist: `Failed*{toast}` (view data in a Message), `Failed*{}`, `Completed*{isX: boolean}`. +- **Target convention:** `Failed*{ reason: }` built with `Effect.catchTags`; `update` maps reason → copy + through one rewritten `error-messages.ts`; one `catchCause` only at the shared failure-toast boundary for defects. +- `Effect.promise` where rejection is possible (`session-bearer` via `rpc-auth-middleware.ts:13`, `desktop-menu.ts:21`) → `tryPromise`. +- `crypto.randomUUID() as XId` ×14 in `core/db/actions.ts` → `XId.make(...)`. + +## P2 — UI state / Model shape + +Recurring Foldkit anti-patterns: boolean soup, nullable-means-loading, derived data stored in the Model, parallel fields. +`AsyncData` and `foldkit/fieldValidation` are used **nowhere** in the app. + +- **Root:** `Session = Loading | SignedOut | SignedIn{ user: AsyncData }` (replaces `auth`+`currentUser`+…; also stops the + endless 10 s `RetryFetchCurrentUser` loop). Drop `pathname` (derive from `route`), route-tag string prefixes + (`route.ts:334`, `app/view.ts`, subscriptions) → `AppRoute` guards, nav Messages carry `AppRoute` not `href`. + `menuSignature` cache key out of Model; `isMobile` from Flags at root; one root clock (sidebar has its own 5 s clock). +- **Chat:** thread panel spread over 5 fields + overlays → one `threadPanel` union; tab stored 3 ways → `Tab = Messages | Files{…}`; + `isSettleWaitPending ≡ isScrolling` → `ScrollPhase`; 6 independent nullable overlays → one `openOverlay` union; + thread rows unkeyed/unmemoized; reaction buttons unkeyed; module state mutated during render (`rowSlots`, `idleCache`, `draftMappers`). +- **Composer:** `Plain | Replying | Editing` union; `DragState`; Mount arg `isDisabled` treated as reactive (captured once); + live editor handles in module Maps → `EditorRegistry` Resource with typed `EditorNotMounted`. +- **Onboarding:** `step` + `form` parallel + 5 flags → one union (good `Machine.define` candidate); finalize failure has no retry; + view/update validation disagree (`" "`); invite rows keyed by index; dead `Data.emails`; `ClickedBack` stale-result race. +- **Settings/auth forms:** per-form `Submission = NotSubmitted | Submitting | Failed{reason}`; `fieldValidation` rules + (shared `channelNameRules`); 5 hand-rolled remote-data unions → `AsyncData`; delete-confirm pattern ×5 → one `ConfirmAction` Submodel. +- **Kit:** Slider/Menu/Select/Tabs copy parent data into child Models (`reflectEntries`, `syncVolume`) → ViewInputs; + Toast lifecycle/timer boolean soup → `Phase`/`Timer` unions; Calendar Range mode is gallery-only → delete; + `navigator.language` read in view/update (`date-field`, `date-segments`) → locale in Model. + +## P3 — Migration cruft to delete + +- **effect-atom:** `core/atoms/*` (write-only `emojiUsageAtom`, dead sound atoms), two module `AtomRegistry`s, + `rpc-atom-client.ts`/`runtime.ts`/`atom-client.ts` (third client stack; **same `"HazelApiClient"` key as `rpc.ts`**), + `ApiClient`, `telemetry.ts`. Blocker for full removal: `optimisticAction` returns `Atom.fn` (needs an Effect-returning variant). +- **Slate:** viewer still runs on `core/components/chat/slate-editor/*`; composer has ported copies in `editor/`. Point viewer at + `editor/`, delete `slate-editor/` + `slate` dep (+ ~1000-line duplicate test suite). +- **Rivet:** ManagedResource acquired but never used; `model.rivet` write-only; dead `getAuthenticatedMessageActor`. +- **Notifications:** `diagnostics-store.ts` never read; `sinks/in-app.ts` no-op. +- **Dead variants/fields:** `UpdatedOrgSlug`, `UpdatedMessages`, `messagesStream`, `viewerImageId`, `onRetryAttempt`, `CompletedSignOut`. +- **Kit:** gallery-only components (list-box, table, tree, toggle(-group), input-otp, text, section-footer, form-error-summary, + field-validation-state, aria/scroll — ~2.5k lines + 1.3k test lines); 9 unused icons; 58 dead `entering:`/`exiting:` classes; + `@foldkit/ui` dependency is never imported. +- **Hand-rolled framework helpers:** `withCommands` ×5, local `toast` ×7, `merge`, hand-folded children → `Update.combine` / + `Update.foldChild`; `embedInteraction` ×3 → `Interaction.embed`. +- **Clerk (frontend):** `SignIn`/`SignUp` `splat` route + `redirect_url` for old Clerk links; comments in `db/actions.ts:472`, test names, `docs/foldkit-decisions/s1-skeleton.md`. +- **Stale comments:** "Shared by the React and Foldkit apps" in ~48 style files; `gallery/boot.ts` → `apps/web`. + +## P4 — Backend (`apps/backend`) Effect errors + +1. **DB error during auth → 401** (`packages/auth/src/consumers/backend-auth.ts:69,132`, `rpc/middleware/auth.ts:84,108`, + `services/oauth-bearer-auth.ts:67`) — a DB blip signs users out. Map to `SessionLoadError` (503). +2. **`Effect.orDie` across `services/org-resolver.ts`** (10 sites) — guards nearly every RPC; DB errors become defects. +3. **SQL/driver text leaks to clients** — `InternalServerError.cause: Schema.Any` + `String(err)` at ~107 sites. Log cause server-side, send stable message only. +4. **Outbox side effects swallow failures** (`message-side-effect-service.ts:46,167,177,192,207,230`) — retry/backoff never sees them; Discord sync permanently dropped on transient outage. +5. **Clerk webhook returns 200 on failure** (`clerk-sync.ts:275`) — never redelivered. +6. **Validation → 500** (`channels.ts:191,363,450`, `incoming-webhooks.http.ts:96,106,161…`) and **not-found → 500** where `ChannelNotFoundError` etc. exist (`connect-shares.ts:252,619,709,579,762`, `invitations.ts:116` downgrades a specific error). +7. Quality: `Effect.die(new Error(...))` in `bots.ts`, `throw` in `Option.getOrElse`, `new Error(String(cause))` as error type, + `catchCause` in `yielded/storage.ts:169` falling through to registration (duplicate-account risk; `users.email` not unique), + GitHub webhook payload cast with `as`, `as any` userId, try/catch around sync decode. +8. **Clerk is half-removed:** backend still requires `CLERK_SECRET_KEY` to boot (`worker/env.ts:29`), Clerk JWT fallback in + `services/auth.ts:50` + `rpc/middleware/auth.ts:62`, lazy Clerk org-sync per request, webhook route, `ClerkUserFetchError` in + auth unions, `@clerk/backend` in 4 packages, dead `scripts/migrate-to-clerk/*`. `oauth-bearer-auth.ts` (Maple OAuth via Clerk IdP) + is a live consumer that needs a replacement first. **CLAUDE.md "Auth: Clerk" and `apps/backendv2` are stale.** + +## Deliberate decisions upheld + +- Keep hand-rolled virtual message list (Foldkit VirtualList still lacks sticky headers, forces list ARIA) — `s4b-virtual-list.md`. +- Keep local Menu (submenus + context menu), Select, RadioGroup. +- `@foldkit/ui` migrations (Dialog/Popover/Tooltip/Toast/Animation) are blocked on `packages/ui-parity` pinning React Aria DOM. + Slider and Tabs could migrate now. + +## Tooling + +- Foldkit DevTools MCP is disabled (`vite.config.ts: devToolsMcpPort: false`) and `@foldkit/devtools-mcp` isn't registered — + enable both to let agents inspect the live Model / replay Messages. From 80ef59a33efd608330009a8823d3312bfcf52b1d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:39:33 +0200 Subject: [PATCH 02/52] chore: remove the UI parity harness and the dev gallery The legacy React app is gone, so packages/ui-parity, the root parity script, the /dev/gallery boot path (and every gallery entry), the S2 data benchmarks that ran on parity fixtures and the normalized-store prototype they drove are dead. Docs that describe the harness are marked historical. Co-Authored-By: Claude Opus 5.5 --- apps/web-foldkit/scripts/bench-s2-data.ts | 67 -- .../scripts/bench-s2/collections.ts | 29 - apps/web-foldkit/scripts/bench-s2/option-a.ts | 127 ---- apps/web-foldkit/scripts/bench-s2/option-b.ts | 133 ---- apps/web-foldkit/scripts/bench-s2/shared.ts | 74 -- .../src/core/dev-gallery/gallery.styles.ts | 9 - apps/web-foldkit/src/data/normalized-store.ts | 186 ----- apps/web-foldkit/src/entry.ts | 15 +- apps/web-foldkit/src/gallery/boot.ts | 17 - apps/web-foldkit/src/gallery/define.ts | 28 - .../web-foldkit/src/gallery/entries/button.ts | 119 ---- .../src/gallery/entries/calendar.ts | 85 --- .../src/gallery/entries/checkbox.ts | 148 ---- .../src/gallery/entries/choice-box.ts | 141 ---- .../src/gallery/entries/combo-box.ts | 95 --- .../src/gallery/entries/command-menu-form.ts | 174 ----- .../src/gallery/entries/command-menu.ts | 118 --- .../src/gallery/entries/composer.ts | 144 ---- .../src/gallery/entries/context-menu.ts | 100 --- .../src/gallery/entries/date-field.ts | 113 --- .../src/gallery/entries/date-picker.ts | 87 --- .../web-foldkit/src/gallery/entries/dialog.ts | 117 --- .../src/gallery/entries/dropdown.ts | 121 ---- .../src/gallery/entries/empty-state.ts | 45 -- .../gallery/entries/field-validation-state.ts | 39 - apps/web-foldkit/src/gallery/entries/field.ts | 89 --- .../src/gallery/entries/form-error-summary.ts | 48 -- .../src/gallery/entries/input-otp.ts | 106 --- apps/web-foldkit/src/gallery/entries/input.ts | 126 ---- apps/web-foldkit/src/gallery/entries/link.ts | 71 -- .../src/gallery/entries/list-box.ts | 128 ---- .../web-foldkit/src/gallery/entries/loader.ts | 31 - apps/web-foldkit/src/gallery/entries/menu.ts | 206 ------ apps/web-foldkit/src/gallery/entries/modal.ts | 113 --- .../src/gallery/entries/popover.ts | 125 ---- .../src/gallery/entries/progress-bar.ts | 47 -- apps/web-foldkit/src/gallery/entries/radio.ts | 108 --- .../src/gallery/entries/range-calendar.ts | 71 -- .../src/gallery/entries/search-field.ts | 127 ---- .../src/gallery/entries/section-header.ts | 82 --- .../web-foldkit/src/gallery/entries/select.ts | 99 --- .../src/gallery/entries/separator.ts | 39 - apps/web-foldkit/src/gallery/entries/sheet.ts | 104 --- .../web-foldkit/src/gallery/entries/slider.ts | 125 ---- .../web-foldkit/src/gallery/entries/switch.ts | 85 --- .../src/gallery/entries/table-selection.ts | 85 --- apps/web-foldkit/src/gallery/entries/table.ts | 156 ---- apps/web-foldkit/src/gallery/entries/tabs.ts | 133 ---- .../src/gallery/entries/text-field.ts | 112 --- apps/web-foldkit/src/gallery/entries/text.ts | 37 - .../src/gallery/entries/textarea.ts | 100 --- .../src/gallery/entries/time-field.ts | 84 --- .../src/gallery/entries/timezone-select.ts | 73 -- apps/web-foldkit/src/gallery/entries/toast.ts | 183 ----- .../src/gallery/entries/toggle-group.ts | 188 ----- .../web-foldkit/src/gallery/entries/toggle.ts | 147 ---- .../src/gallery/entries/toolbar.ts | 82 --- .../src/gallery/entries/tooltip.ts | 131 ---- apps/web-foldkit/src/gallery/entries/tree.ts | 85 --- apps/web-foldkit/src/gallery/frame.ts | 41 -- apps/web-foldkit/src/gallery/interaction.ts | 7 - apps/web-foldkit/src/page/README.md | 5 - apps/web-foldkit/src/route.test.ts | 2 +- apps/web-foldkit/src/ui/toast-view.ts | 3 - bun.lock | 30 - docs/foldkit-decisions/s1-skeleton.md | 2 + docs/foldkit-decisions/s2-data.md | 2 + docs/foldkit-decisions/s3-editor.md | 2 + docs/foldkit-decisions/s4-chat-list.md | 2 + docs/foldkit-decisions/s4b-virtual-list.md | 2 + docs/foldkit-migration.md | 2 + docs/foldkit-orchestrator-prompt.md | 2 + docs/foldkit-review/2026-10-full-audit.md | 2 +- package.json | 1 - packages/ui-parity/.gitignore | 1 - packages/ui-parity/README.md | 146 ---- packages/ui-parity/package.json | 24 - packages/ui-parity/src/backend/assets.ts | 72 -- packages/ui-parity/src/backend/call-log.ts | 69 -- packages/ui-parity/src/backend/electric.ts | 139 ---- packages/ui-parity/src/backend/live-events.ts | 72 -- packages/ui-parity/src/backend/network.ts | 75 -- packages/ui-parity/src/backend/rivet.ts | 177 ----- packages/ui-parity/src/backend/rpc.ts | 179 ----- packages/ui-parity/src/backend/server.ts | 102 --- packages/ui-parity/src/behavior.ts | 156 ---- .../ui-parity/src/bench/chat-list-probes.ts | 276 ------- packages/ui-parity/src/bench/chat-list.ts | 184 ----- packages/ui-parity/src/bench/perf-probes.ts | 245 ------- packages/ui-parity/src/bench/perf-trace.ts | 150 ---- packages/ui-parity/src/bench/perf.ts | 673 ------------------ packages/ui-parity/src/bench/render-trace.ts | 81 --- packages/ui-parity/src/bench/sidebar-diag.ts | 109 --- packages/ui-parity/src/bench/style-bisect.ts | 130 ---- packages/ui-parity/src/bench/switch-trace.ts | 222 ------ packages/ui-parity/src/build.ts | 44 -- packages/ui-parity/src/capture.ts | 237 ------ packages/ui-parity/src/cli.ts | 311 -------- packages/ui-parity/src/codegen.ts | 119 ---- packages/ui-parity/src/compare.ts | 318 --------- packages/ui-parity/src/config.ts | 83 --- packages/ui-parity/src/coverage.ts | 30 - packages/ui-parity/src/fixtures/dataset.ts | 73 -- .../fixtures/datasets/chat-sync-discord.ts | 92 --- .../src/fixtures/datasets/default.ts | 238 ------- .../ui-parity/src/fixtures/datasets/embeds.ts | 374 ---------- .../ui-parity/src/fixtures/datasets/empty.ts | 101 --- .../ui-parity/src/fixtures/datasets/entry.ts | 67 -- .../ui-parity/src/fixtures/datasets/errors.ts | 56 -- .../src/fixtures/datasets/heavy-content.ts | 241 ------- .../ui-parity/src/fixtures/datasets/heavy.ts | 342 --------- .../ui-parity/src/fixtures/datasets/inbox.ts | 160 ----- .../src/fixtures/datasets/integrations-rpc.ts | 155 ---- .../src/fixtures/datasets/integrations.ts | 223 ------ .../ui-parity/src/fixtures/datasets/member.ts | 30 - .../src/fixtures/datasets/onboarding.ts | 50 -- .../src/fixtures/datasets/personal.ts | 145 ---- .../ui-parity/src/fixtures/datasets/rich.ts | 165 ----- .../src/fixtures/datasets/rich/base.ts | 86 --- .../fixtures/datasets/rich/conversation.ts | 200 ------ .../src/fixtures/datasets/rich/emoji.ts | 52 -- .../fixtures/datasets/rich/integrations.ts | 288 -------- .../src/fixtures/datasets/rich/media.ts | 86 --- .../src/fixtures/datasets/status-emoji.ts | 133 ---- .../src/fixtures/datasets/status-set.ts | 57 -- packages/ui-parity/src/fixtures/identity.ts | 18 - packages/ui-parity/src/fixtures/ids.ts | 11 - packages/ui-parity/src/report.ts | 353 --------- packages/ui-parity/src/runtime/clerk-stub.ts | 139 ---- packages/ui-parity/src/runtime/snapshot.ts | 249 ------- packages/ui-parity/src/runtime/stabilize.ts | 142 ---- packages/ui-parity/src/scenarios.ts | 68 -- .../ui-parity/src/scenarios/chat-embeds.ts | 123 ---- packages/ui-parity/src/scenarios/chat-list.ts | 26 - .../ui-parity/src/scenarios/chat-overlays.ts | 135 ---- packages/ui-parity/src/scenarios/chat.ts | 179 ----- packages/ui-parity/src/scenarios/composer.ts | 55 -- packages/ui-parity/src/scenarios/entry.ts | 212 ------ .../ui-parity/src/scenarios/gallery-data.ts | 236 ------ .../ui-parity/src/scenarios/gallery-kit.ts | 278 -------- .../src/scenarios/gallery-overlays.ts | 503 ------------- packages/ui-parity/src/scenarios/gallery.ts | 523 -------------- packages/ui-parity/src/scenarios/home.ts | 136 ---- .../ui-parity/src/scenarios/integrations.ts | 159 ----- .../ui-parity/src/scenarios/my-settings.ts | 248 ------- .../ui-parity/src/scenarios/navigation.ts | 345 --------- .../ui-parity/src/scenarios/notifications.ts | 67 -- packages/ui-parity/src/scenarios/settings.ts | 401 ----------- packages/ui-parity/src/scenarios/types.ts | 53 -- packages/ui-parity/src/serve.ts | 33 - packages/ui-parity/tsconfig.json | 26 - 151 files changed, 21 insertions(+), 18497 deletions(-) delete mode 100644 apps/web-foldkit/scripts/bench-s2-data.ts delete mode 100644 apps/web-foldkit/scripts/bench-s2/collections.ts delete mode 100644 apps/web-foldkit/scripts/bench-s2/option-a.ts delete mode 100644 apps/web-foldkit/scripts/bench-s2/option-b.ts delete mode 100644 apps/web-foldkit/scripts/bench-s2/shared.ts delete mode 100644 apps/web-foldkit/src/core/dev-gallery/gallery.styles.ts delete mode 100644 apps/web-foldkit/src/data/normalized-store.ts delete mode 100644 apps/web-foldkit/src/gallery/boot.ts delete mode 100644 apps/web-foldkit/src/gallery/define.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/button.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/calendar.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/checkbox.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/choice-box.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/combo-box.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/command-menu-form.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/command-menu.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/composer.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/context-menu.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/date-field.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/date-picker.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/dialog.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/dropdown.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/empty-state.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/field-validation-state.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/field.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/form-error-summary.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/input-otp.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/input.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/link.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/list-box.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/loader.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/menu.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/modal.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/popover.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/progress-bar.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/radio.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/range-calendar.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/search-field.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/section-header.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/select.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/separator.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/sheet.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/slider.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/switch.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/table-selection.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/table.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/tabs.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/text-field.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/text.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/textarea.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/time-field.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/timezone-select.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/toast.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/toggle-group.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/toggle.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/toolbar.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/tooltip.ts delete mode 100644 apps/web-foldkit/src/gallery/entries/tree.ts delete mode 100644 apps/web-foldkit/src/gallery/frame.ts delete mode 100644 apps/web-foldkit/src/gallery/interaction.ts delete mode 100644 packages/ui-parity/.gitignore delete mode 100644 packages/ui-parity/README.md delete mode 100644 packages/ui-parity/package.json delete mode 100644 packages/ui-parity/src/backend/assets.ts delete mode 100644 packages/ui-parity/src/backend/call-log.ts delete mode 100644 packages/ui-parity/src/backend/electric.ts delete mode 100644 packages/ui-parity/src/backend/live-events.ts delete mode 100644 packages/ui-parity/src/backend/network.ts delete mode 100644 packages/ui-parity/src/backend/rivet.ts delete mode 100644 packages/ui-parity/src/backend/rpc.ts delete mode 100644 packages/ui-parity/src/backend/server.ts delete mode 100644 packages/ui-parity/src/behavior.ts delete mode 100644 packages/ui-parity/src/bench/chat-list-probes.ts delete mode 100644 packages/ui-parity/src/bench/chat-list.ts delete mode 100644 packages/ui-parity/src/bench/perf-probes.ts delete mode 100644 packages/ui-parity/src/bench/perf-trace.ts delete mode 100644 packages/ui-parity/src/bench/perf.ts delete mode 100644 packages/ui-parity/src/bench/render-trace.ts delete mode 100644 packages/ui-parity/src/bench/sidebar-diag.ts delete mode 100644 packages/ui-parity/src/bench/style-bisect.ts delete mode 100644 packages/ui-parity/src/bench/switch-trace.ts delete mode 100644 packages/ui-parity/src/build.ts delete mode 100644 packages/ui-parity/src/capture.ts delete mode 100644 packages/ui-parity/src/cli.ts delete mode 100644 packages/ui-parity/src/codegen.ts delete mode 100644 packages/ui-parity/src/compare.ts delete mode 100644 packages/ui-parity/src/config.ts delete mode 100644 packages/ui-parity/src/coverage.ts delete mode 100644 packages/ui-parity/src/fixtures/dataset.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/chat-sync-discord.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/default.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/embeds.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/empty.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/entry.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/errors.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/heavy-content.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/heavy.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/inbox.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/integrations-rpc.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/integrations.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/member.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/onboarding.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/personal.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich/base.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich/conversation.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich/emoji.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich/integrations.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/rich/media.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/status-emoji.ts delete mode 100644 packages/ui-parity/src/fixtures/datasets/status-set.ts delete mode 100644 packages/ui-parity/src/fixtures/identity.ts delete mode 100644 packages/ui-parity/src/fixtures/ids.ts delete mode 100644 packages/ui-parity/src/report.ts delete mode 100644 packages/ui-parity/src/runtime/clerk-stub.ts delete mode 100644 packages/ui-parity/src/runtime/snapshot.ts delete mode 100644 packages/ui-parity/src/runtime/stabilize.ts delete mode 100644 packages/ui-parity/src/scenarios.ts delete mode 100644 packages/ui-parity/src/scenarios/chat-embeds.ts delete mode 100644 packages/ui-parity/src/scenarios/chat-list.ts delete mode 100644 packages/ui-parity/src/scenarios/chat-overlays.ts delete mode 100644 packages/ui-parity/src/scenarios/chat.ts delete mode 100644 packages/ui-parity/src/scenarios/composer.ts delete mode 100644 packages/ui-parity/src/scenarios/entry.ts delete mode 100644 packages/ui-parity/src/scenarios/gallery-data.ts delete mode 100644 packages/ui-parity/src/scenarios/gallery-kit.ts delete mode 100644 packages/ui-parity/src/scenarios/gallery-overlays.ts delete mode 100644 packages/ui-parity/src/scenarios/gallery.ts delete mode 100644 packages/ui-parity/src/scenarios/home.ts delete mode 100644 packages/ui-parity/src/scenarios/integrations.ts delete mode 100644 packages/ui-parity/src/scenarios/my-settings.ts delete mode 100644 packages/ui-parity/src/scenarios/navigation.ts delete mode 100644 packages/ui-parity/src/scenarios/notifications.ts delete mode 100644 packages/ui-parity/src/scenarios/settings.ts delete mode 100644 packages/ui-parity/src/scenarios/types.ts delete mode 100644 packages/ui-parity/src/serve.ts delete mode 100644 packages/ui-parity/tsconfig.json 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/core/dev-gallery/gallery.styles.ts b/apps/web-foldkit/src/core/dev-gallery/gallery.styles.ts deleted file mode 100644 index 1a7e69e88..000000000 --- a/apps/web-foldkit/src/core/dev-gallery/gallery.styles.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Page chrome for `/dev/gallery/$component`, shared by both apps. Keep this file framework-free. */ -export const galleryStyles = { - page: "min-h-screen bg-bg p-8 text-fg", - title: "mb-6 font-semibold text-lg", - sections: "flex flex-col gap-8", - section: "flex flex-col gap-3", - sectionTitle: "font-medium text-muted-fg text-sm", - row: "flex flex-wrap items-start gap-3", -} as const diff --git a/apps/web-foldkit/src/data/normalized-store.ts b/apps/web-foldkit/src/data/normalized-store.ts deleted file mode 100644 index 019938daa..000000000 --- a/apps/web-foldkit/src/data/normalized-store.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { ChannelId, MessageId, UserId } from "@hazel/schema" -import { Schema } from "effect" -import { defineMessageUnion } from "foldkit/message" -import { ChatAuthor, ChatMessage, ChatReaction } from "../page/chat/rows" -import { type MessageQueryRow, toChatMessage, toChatReaction } from "../page/chat/queries" - -/** - * S2 option (b), prototype for measurement only: every synced message, user and reaction held in - * the Foldkit Model as a normalized store, updated incrementally from Electric shape change rows. - * Not wired into the app; `scripts/bench-s2-data.ts` drives it against the heavy dataset. - */ - -export const Store = Schema.Struct({ - messagesById: Schema.Record(Schema.String, ChatMessage), - /** Message ids per channel, oldest first. */ - messageIdsByChannel: Schema.Record(Schema.String, Schema.Array(Schema.String)), - usersById: Schema.Record(Schema.String, ChatAuthor), - reactionsByMessage: Schema.Record(Schema.String, Schema.Array(ChatReaction)), -}) -export type Store = typeof Store.Type - -export const emptyStore: Store = { - messagesById: {}, - messageIdsByChannel: {}, - usersById: {}, - reactionsByMessage: {}, -} - -/** One decoded Electric change message (`headers.operation` + `value`), reduced to what the store reads. */ -export type ShapeChange = - | { readonly table: "users"; readonly operation: "insert" | "update"; readonly row: UserRow } - | { - readonly table: "messages" - readonly operation: "insert" | "update" | "delete" - readonly row: MessageRow - } - | { - readonly table: "message_reactions" - readonly operation: "insert" | "delete" - readonly row: ReactionRow - } - -export interface UserRow { - readonly id: string - readonly firstName: string - readonly lastName: string - readonly avatarUrl: string | null - readonly userType: string -} -export type MessageRow = Omit -export interface ReactionRow { - readonly id: string - readonly messageId: MessageQueryRow["id"] - readonly userId: MessageQueryRow["authorId"] - readonly emoji: string -} - -const UserRowSchema = Schema.Struct({ - id: Schema.String, - firstName: Schema.String, - lastName: Schema.String, - avatarUrl: Schema.NullOr(Schema.String), - userType: Schema.String, -}) -const MessageRowSchema = Schema.Struct({ - id: MessageId, - channelId: ChannelId, - authorId: UserId, - content: Schema.String, - embeds: Schema.NullOr(Schema.Array(Schema.Unknown)), - replyToMessageId: Schema.NullOr(MessageId), - threadChannelId: Schema.NullOr(ChannelId), - createdAt: Schema.Date, - updatedAt: Schema.NullOr(Schema.Date), -}) -const ReactionRowSchema = Schema.Struct({ - id: Schema.String, - messageId: MessageId, - userId: UserId, - emoji: Schema.String, -}) - -/** The Message that would carry shape changes into update (validated on construction, like every Message). */ -export const StoreMessage = defineMessageUnion({ - ReceivedShapeChanges: { - changes: Schema.Array( - Schema.Union([ - Schema.Struct({ - table: Schema.Literal("users"), - operation: Schema.Literals(["insert", "update"]), - row: UserRowSchema, - }), - Schema.Struct({ - table: Schema.Literal("messages"), - operation: Schema.Literals(["insert", "update", "delete"]), - row: MessageRowSchema, - }), - Schema.Struct({ - table: Schema.Literal("message_reactions"), - operation: Schema.Literals(["insert", "delete"]), - row: ReactionRowSchema, - }), - ]), - ), - }, -}) - -/** Applies a batch of changes with copy-on-write records, so unchanged branches keep their references. */ -export const applyChanges = (store: Store, changes: ReadonlyArray): Store => { - const usersById = { ...store.usersById } - let messagesById: Record | undefined - let messageIdsByChannel: Record> | undefined - // Channel id lists touched by this batch: copied once, appended to, sorted once at the end. - const touchedChannels = new Map() - const idsFor = (channelId: string) => { - let ids = touchedChannels.get(channelId) - if (ids === undefined) - touchedChannels.set(channelId, (ids = [...(store.messageIdsByChannel[channelId] ?? [])])) - return ids - } - let reactionsByMessage: Record> | undefined - let usersChanged = false - for (const change of changes) { - if (change.table === "users") { - usersById[change.row.id] = { - firstName: change.row.firstName, - lastName: change.row.lastName, - avatarUrl: change.row.avatarUrl, - userType: change.row.userType, - } - usersChanged = true - } else if (change.table === "messages") { - messagesById ??= { ...store.messagesById } - messageIdsByChannel ??= { ...store.messageIdsByChannel } - const { row } = change - if (change.operation === "delete") { - delete messagesById[row.id] - const ids = idsFor(row.channelId) - ids.splice(ids.indexOf(row.id), 1) - } else { - const isNew = messagesById[row.id] === undefined - messagesById[row.id] = toChatMessage({ - ...row, - author: usersById[row.authorId] ?? null, - pinnedMessage: null, - }) - if (isNew) idsFor(row.channelId).push(row.id) - } - } else { - reactionsByMessage ??= { ...store.reactionsByMessage } - const current = reactionsByMessage[change.row.messageId] ?? [] - reactionsByMessage[change.row.messageId] = - change.operation === "delete" - ? current.filter((reaction) => reaction.id !== change.row.id) - : [...current, toChatReaction(change.row)] - } - } - for (const [channelId, ids] of touchedChannels) { - const byId = messagesById ?? store.messagesById - // Usually already in order (a new message is the newest), so the check skips the sort. - if (ids.some((id, index) => index > 0 && byId[ids[index - 1]!]!.createdAtMs > byId[id]!.createdAtMs)) - ids.sort((a, b) => byId[a]!.createdAtMs - byId[b]!.createdAtMs) - messageIdsByChannel![channelId] = ids - } - return { - usersById: usersChanged ? usersById : store.usersById, - messagesById: messagesById ?? store.messagesById, - messageIdsByChannel: messageIdsByChannel ?? store.messageIdsByChannel, - reactionsByMessage: reactionsByMessage ?? store.reactionsByMessage, - } -} - -/** The page's view of the store: the newest `limit` messages of a channel, newest first. */ -export const channelWindow = (store: Store, channelId: string, limit: number): ReadonlyArray => { - const ids = store.messageIdsByChannel[channelId] ?? [] - const window: ChatMessage[] = [] - for (let index = ids.length - 1; index >= 0 && window.length < limit; index--) - window.push(store.messagesById[ids[index]!]!) - return window -} - -/** Reactions of the messages in a window, in arrival order per message. */ -export const windowReactions = ( - store: Store, - window: ReadonlyArray, -): ReadonlyArray => window.flatMap((message) => store.reactionsByMessage[message.id] ?? []) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index 68ab8a08a..d67ee2981 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -7,7 +7,6 @@ import { setSessionBearerProvider } from "~/lib/session-bearer" import { initTauri } from "~/lib/tauri" import { isDesktop } from "./platform/desktop" import { desktopSessionBearer } from "./platform/desktop-auth" -import { galleryComponentOf, startGallery } from "./gallery/boot" import { Flags, flags, init, managedResources, Message, Model, subscriptions, update, view } from "./main" import { ResourcesLive } from "./rpc" @@ -34,12 +33,8 @@ const startApplication = () => { flags }, ) -// `/dev/gallery/` boots one UI primitive's gallery program instead of the app (parity scenarios). -const galleryComponent = galleryComponentOf(location.pathname) -if (galleryComponent === undefined) { - // The desktop app has no API cookie: its requests carry its own session as a bearer. - if (isDesktop) setSessionBearerProvider(desktopSessionBearer) - startApplication() - // Desktop only: native notification permission (a no-op in the browser). - void initTauri() -} else startGallery(galleryComponent, container) +// The desktop app has no API cookie: its requests carry its own session as a bearer. +if (isDesktop) setSessionBearerProvider(desktopSessionBearer) +startApplication() +// Desktop only: native notification permission (a no-op in the browser). +void initTauri() diff --git a/apps/web-foldkit/src/gallery/boot.ts b/apps/web-foldkit/src/gallery/boot.ts deleted file mode 100644 index e6d8f5952..000000000 --- a/apps/web-foldkit/src/gallery/boot.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { GalleryEntry } from "./define" - -/** One module per primitive in `./entries/.ts`, mirroring `apps/web/src/dev-gallery/entries/.tsx`. */ -const entries = new Map( - Object.entries(import.meta.glob<{ gallery: GalleryEntry }>("./entries/*.ts", { eager: true })).map( - ([path, module]) => [path.replace(/^.*\/(.+)\.ts$/, "$1"), module.gallery], - ), -) - -export const galleryComponentOf = (pathname: string): string | undefined => - pathname.match(/^\/dev\/gallery\/([^/]+)\/?$/)?.[1] - -export const startGallery = (component: string, container: HTMLElement) => { - const entry = entries.get(component) - if (entry) return entry.start(container) - container.textContent = `Unknown gallery component: ${component}. Known: ${[...entries.keys()].join(", ")}` -} diff --git a/apps/web-foldkit/src/gallery/define.ts b/apps/web-foldkit/src/gallery/define.ts deleted file mode 100644 index a8ef14dcd..000000000 --- a/apps/web-foldkit/src/gallery/define.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Effect } from "effect" -import { Runtime } from "foldkit" -import { HazelRpc, HazelRpcLive } from "../rpc" -import { applyTheme, defaultCustomization, resolveSystemTheme } from "../theme" - -/** - * A gallery entry is a self-contained Foldkit program for one primitive, booted on - * `/dev/gallery/` instead of the main app. Closing over the program keeps each - * entry fully typed while the registry only holds `start` functions. - */ -export interface GalleryEntry { - readonly title: string - readonly start: (container: HTMLElement) => void -} - -export const defineGallery = ( - title: string, - program: Omit, "container" | "resources">, -): GalleryEntry => ({ - title, - start: (container) => { - // Same theme effects as the app's ThemeProvider port; captures fix the color scheme, so once is enough. - Effect.runSync(applyTheme(resolveSystemTheme(), defaultCustomization())) - Runtime.run( - Runtime.makeElement({ ...program, container, resources: HazelRpcLive }), - ) - }, -}) diff --git a/apps/web-foldkit/src/gallery/entries/button.ts b/apps/web-foldkit/src/gallery/entries/button.ts deleted file mode 100644 index 8afe54b7b..000000000 --- a/apps/web-foldkit/src/gallery/entries/button.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconPlus } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import { button, type ButtonOptions } from "../../ui/button" -import { loader } from "../../ui/loader" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - lastClicked: Schema.NullOr(Schema.String), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ClickedButton: { label: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - ClickedButton: ({ label }) => ({ model: modifyFields(model, { lastClicked: () => label }) }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const intents = ["primary", "secondary", "warning", "danger", "outline", "plain"] as const -const sizes = ["xs", "sm", "md", "lg"] as const -const squareSizes = ["sq-xs", "sq-sm", "sq-md", "sq-lg"] as const - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - // Every button gets its own interaction target; the label doubles as the target id. - const demo = (label: string, options: ButtonOptions = {}) => ({ - ...options, - onPress: Message.ClickedButton({ label }), - interaction: { wiring, target: label }, - }) - const ariaLabel = (label: string) => [h.AriaLabel(label)] - return galleryFrame(h, "Button", [ - gallerySection( - h, - "Intents", - intents.map((intent) => button(h, demo(intent, { intent }), [intent])), - ), - gallerySection( - h, - "Sizes", - sizes.map((size) => button(h, demo(`size ${size}`, { size }), [`Size ${size}`])), - ), - gallerySection(h, "Circle", [ - button(h, demo("circle", { isCircle: true }), ["Circle"]), - button(h, demo("circle outline", { isCircle: true, intent: "outline" }), ["Circle outline"]), - ]), - gallerySection(h, "Icons", [ - ...sizes.map((size) => - button(h, demo(`add ${size}`, { size, intent: "outline" }), [IconPlus(h), `Add ${size}`]), - ), - button(h, demo("trailing", { intent: "secondary" }), ["Trailing", IconPlus(h)]), - ]), - gallerySection(h, "Square sizes", [ - ...squareSizes.map((size) => - button( - h, - { - ...demo(`add ${size}`, { size, intent: "outline" }), - attributes: ariaLabel(`Add ${size}`), - }, - [IconPlus(h)], - ), - ), - button( - h, - { - ...demo("add circle", { size: "sq-md", isCircle: true }), - attributes: ariaLabel("Add circle"), - }, - [IconPlus(h)], - ), - ]), - gallerySection( - h, - "Disabled", - intents.map((intent) => - button(h, demo(`disabled ${intent}`, { intent, isDisabled: true }), [`Disabled ${intent}`]), - ), - ), - gallerySection(h, "Pending", [ - button(h, demo("saving", { isPending: true }), [loader(h), "Saving"]), - button(h, demo("loading", { intent: "outline", isPending: true }), [ - loader(h, { variant: "ring" }), - "Loading", - ]), - ]), - ]) -} - -export const gallery = defineGallery("Button", { - Model, - init: () => ({ model: { lastClicked: null, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/calendar.ts b/apps/web-foldkit/src/gallery/entries/calendar.ts deleted file mode 100644 index 00d5631d1..000000000 --- a/apps/web-foldkit/src/gallery/entries/calendar.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Calendar from "../../ui/calendar" -import * as D from "../../ui/calendar-date" -import { view as calendarView } from "../../ui/calendar-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Slot = Schema.Literals(["event", "deadline"]) -type Slot = typeof Slot.Type - -const Model = Schema.Struct({ event: Calendar.Model, deadline: Calendar.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotCalendarMessage: { slotId: Slot, message: Calendar.Message }, -}) -type Message = typeof Message.Type - -const toMessage = (slot: Slot) => (message: Calendar.Message) => - Message.GotCalendarMessage({ slotId: slot, message }) - -// UPDATE - -const fold = (slot: Slot) => - Update.foldChild({ - update: Calendar.update, - read: (model: Model) => Option.some(model[slot]), - write: (model: Model, next: Calendar.Model): Model => ({ ...model, [slot]: next }), - toParentMessage: toMessage(slot), - foldOutMessage: () => (model: Model) => ({ model }), - }) - -const lift = (slot: Slot) => - Subscription.lift(Calendar.subscriptions)({ - read: (model) => Option.some(model[slot]), - toParentMessage: toMessage(slot), - }) - -const subscriptions = Subscription.aggregate()(lift("event"), { - deadlineModality: lift("deadline").modality, - deadlinePointerRelease: lift("deadline").pointerRelease, -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const calendar = (slot: Slot, ariaLabel: string) => - h.submodel({ - slotId: slot, - model: model[slot], - view: calendarView, - viewInputs: { ariaLabel }, - toParentMessage: toMessage(slot), - }) - return galleryFrame(h, "Calendar", [ - gallerySection(h, "Selected date", [calendar("event", "Event date")]), - gallerySection(h, "Minimum date", [calendar("deadline", "Deadline")]), - ]) -} - -export const gallery = defineGallery("Calendar", { - Model, - init: () => { - const today = D.today() - return { - model: { - event: Calendar.init({ id: "event", today, value: "2026-03-18" }), - deadline: Calendar.init({ id: "deadline", today, minValue: "2026-03-10" }), - }, - } - }, - update: (model, message) => - Message.match>(message, { - GotCalendarMessage: ({ slotId: slot, message }) => fold(slot)(model, message), - }), - subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/checkbox.ts b/apps/web-foldkit/src/gallery/entries/checkbox.ts deleted file mode 100644 index 0c3342a87..000000000 --- a/apps/web-foldkit/src/gallery/entries/checkbox.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { checkbox, checkboxGroup, type CheckboxOptions } from "../../ui/checkbox" -import { description, label } from "../../ui/field" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - selected: Schema.Record(Schema.String, Schema.Boolean), - notify: Schema.Array(Schema.String), - terms: Schema.Array(Schema.String), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ToggledCheckbox: { id: Schema.String, isSelected: Schema.Boolean }, - UpdatedNotify: { value: Schema.Array(Schema.String) }, - UpdatedTerms: { value: Schema.Array(Schema.String) }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - ToggledCheckbox: ({ id, isSelected }) => ({ - model: modifyFields(model, { selected: (selected) => ({ ...selected, [id]: isSelected }) }), - }), - UpdatedNotify: ({ value }) => ({ model: modifyFields(model, { notify: () => value }) }), - UpdatedTerms: ({ value }) => ({ model: modifyFields(model, { terms: () => value }) }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialSelected: Record = { - unchecked: false, - checked: true, - indeterminate: false, - disabled: false, - "disabled-checked": true, - invalid: false, - "invalid-checked": true, - email: false, -} - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - const item = (id: string, options: Omit, "id" | "isSelected"> = {}) => ({ - id, - isSelected: model.selected[id] ?? false, - onChange: (isSelected: boolean) => Message.ToggledCheckbox({ id, isSelected }), - interaction: wiring, - ...options, - }) - return galleryFrame(h, "Checkbox", [ - gallerySection(h, "States", [ - checkbox(h, item("unchecked"), "Unchecked"), - checkbox(h, item("checked"), "Checked"), - checkbox(h, item("indeterminate", { isIndeterminate: true }), "Indeterminate"), - checkbox(h, item("disabled", { isDisabled: true }), "Disabled"), - checkbox(h, item("disabled-checked", { isDisabled: true }), "Disabled checked"), - checkbox(h, item("invalid", { isInvalid: true }), "Invalid"), - checkbox(h, item("invalid-checked", { isInvalid: true }), "Invalid checked"), - ]), - gallerySection(h, "With description", [ - h.div( - [h.Class("w-80")], - [ - checkbox(h, item("email"), [ - label(h, {}, ["Email notifications"]), - description(h, {}, ["Get a digest of unread messages."]), - ]), - ], - ), - ]), - gallerySection(h, "Group", [ - h.div( - [h.Class("w-80")], - [ - checkboxGroup( - h, - { - id: "notify", - value: model.notify, - onChange: (value) => Message.UpdatedNotify({ value }), - interaction: wiring, - }, - (group) => [ - group.label(["Notify me about"]), - group.checkbox("mentions", "Mentions"), - group.checkbox("replies", "Replies"), - group.checkbox("reactions", "Reactions"), - ], - ), - ], - ), - h.div( - [h.Class("w-80")], - [ - checkboxGroup( - h, - { - id: "terms", - value: model.terms, - isInvalid: true, - onChange: (value) => Message.UpdatedTerms({ value }), - interaction: wiring, - }, - (group) => [ - group.label(["Accept the terms"]), - group.checkbox("terms", "I agree to the terms"), - group.fieldError(["You must accept the terms."]), - ], - ), - ], - ), - ]), - ]) -} - -export const gallery = defineGallery("Checkbox", { - Model, - init: () => ({ - model: { - selected: initialSelected, - notify: ["mentions"], - terms: [], - interaction: Interaction.init(), - }, - }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/choice-box.ts b/apps/web-foldkit/src/gallery/entries/choice-box.ts deleted file mode 100644 index 2d0b1d151..000000000 --- a/apps/web-foldkit/src/gallery/entries/choice-box.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconRocket, IconUser, IconUsers } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import * as ChoiceBox from "../../ui/choice-box" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const BoxKey = Schema.Literals(["team", "plan", "channels"]) -type BoxKey = typeof BoxKey.Type - -const Model = Schema.Struct({ - team: ChoiceBox.Model, - plan: ChoiceBox.Model, - channels: ChoiceBox.Model, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotChoiceBoxMessage: { boxId: BoxKey, message: ChoiceBox.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -const toBoxMessage = - (box: BoxKey) => - (message: ChoiceBox.Message): Message => - Message.GotChoiceBoxMessage({ boxId: box, message }) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotChoiceBoxMessage: ({ boxId: box, message }) => - Update.foldChild({ - update: ChoiceBox.update, - read: (current: Model) => Option.some(current[box]), - write: (current: Model, next: ChoiceBox.Model): Model => ({ ...current, [box]: next }), - toParentMessage: toBoxMessage(box), - })(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const common = (box: BoxKey) => ({ - model: model[box], - toParentMessage: toBoxMessage(box), - interaction: interaction.wiring(model), - }) - return galleryFrame(h, "Choice box", [ - gallerySection(h, "Grid", [ - h.div( - [h.Class("w-[36rem]")], - ChoiceBox.choiceBox(h, { ...common("team"), ariaLabel: "Team size", columns: 2, gap: 4 }, [ - { - key: "solo", - textValue: "Just me", - content: (parts) => [ - IconUser(h), - parts.label(["Just me"]), - parts.description(["Personal workspace"]), - ], - }, - { - key: "small", - textValue: "2-10", - content: (parts) => [ - IconUsers(h), - parts.label(["2-10"]), - parts.description(["Small team"]), - ], - }, - { - key: "large", - textValue: "11+", - content: (parts) => [ - IconRocket(h), - parts.label(["11+"]), - parts.description(["Growing company"]), - ], - }, - ]), - ), - ]), - gallerySection(h, "Stack", [ - h.div( - [h.Class("w-96")], - ChoiceBox.choiceBox(h, { ...common("plan"), ariaLabel: "Plan" }, [ - { key: "free", label: "Free", description: "For trying things out" }, - { key: "pro", label: "Pro", description: "For growing teams" }, - { - key: "enterprise", - label: "Enterprise", - description: "Talk to sales", - isDisabled: true, - }, - ]), - ), - ]), - gallerySection(h, "Multiple", [ - h.div( - [h.Class("w-96")], - ChoiceBox.choiceBox(h, { ...common("channels"), ariaLabel: "Channels" }, [ - { key: "general", label: "General", description: "Company-wide updates" }, - { key: "random", label: "Random", description: "Everything else" }, - ]), - ), - ]), - ]) -} - -export const gallery = defineGallery("Choice box", { - Model, - init: () => ({ - model: { - team: ChoiceBox.init({ id: "team", selectedKeys: ["small"] }), - plan: ChoiceBox.init({ id: "plan", selectedKeys: ["pro"] }), - channels: ChoiceBox.init({ - id: "channels", - selectionMode: "multiple", - selectedKeys: ["general"], - }), - interaction: Interaction.init(), - }, - }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/combo-box.ts b/apps/web-foldkit/src/gallery/entries/combo-box.ts deleted file mode 100644 index 585eefd1a..000000000 --- a/apps/web-foldkit/src/gallery/entries/combo-box.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Array, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { isAppleDevice } from "../../ui/aria/announcer" -import * as ComboBox from "../../ui/combo-box" -import { view as comboBoxView } from "../../ui/combo-box-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ comboBoxes: Schema.Array(ComboBox.Model) }) -type Model = typeof Model.Type - -const channels = [ - ComboBox.item("general", "General"), - ComboBox.item("design", "Design"), - ComboBox.item("engineering", "Engineering"), - ComboBox.item("random", "Random"), - ComboBox.item("product", "Product updates"), -] - -const labels: Readonly> = { channel: "Channel", selected: "Selected" } - -// MESSAGE - -const Message = defineMessageUnion({ - GotComboBoxMessage: { id: Schema.String, message: ComboBox.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldComboBoxOutMessage = ComboBox.OutMessage.match>({ - ChangedSelection: () => (model) => ({ model }), -}) - -const foldComboBox = (id: string) => - Update.foldChild({ - update: ComboBox.update, - read: (model: Model) => Array.findFirst(model.comboBoxes, (comboBox) => comboBox.id === id), - write: (model, nextComboBox) => - modifyFields(model, { - comboBoxes: Array.map((comboBox) => (comboBox.id === id ? nextComboBox : comboBox)), - }), - toParentMessage: (message) => Message.GotComboBoxMessage({ id, message }), - foldOutMessage: foldComboBoxOutMessage, - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Combo box", [ - gallerySection( - h, - "Combo box", - Array.map(model.comboBoxes, (comboBox) => - h.submodel({ - slotId: comboBox.id, - model: comboBox, - view: comboBoxView, - viewInputs: { - label: labels[comboBox.id] ?? "", - placeholder: "Search channels", - className: "w-64", - }, - toParentMessage: (message) => Message.GotComboBoxMessage({ id: comboBox.id, message }), - }), - ), - ), - ]) - -export const gallery = defineGallery("Combo box", { - Model, - init: () => ({ - model: { - comboBoxes: [ - ComboBox.init({ id: "channel", items: channels, isAppleDevice: isAppleDevice() }), - ComboBox.init({ - id: "selected", - items: channels, - selectedKey: "design", - isAppleDevice: isAppleDevice(), - }), - ], - }, - }), - update: (model, message) => - Message.match>(message, { - GotComboBoxMessage: ({ id, message }) => foldComboBox(id)(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/command-menu-form.ts b/apps/web-foldkit/src/gallery/entries/command-menu-form.ts deleted file mode 100644 index 225f9ae39..000000000 --- a/apps/web-foldkit/src/gallery/entries/command-menu-form.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconHashtag, IconLock } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import * as CommandMenu from "../../ui/command-menu" -import { - commandMenuFormBody, - commandMenuFormContainer, - commandMenuFormField, - commandMenuFormFooter, - commandMenuFormHeader, - commandMenuInput, - commandMenuToggle, -} from "../../ui/command-menu-form" -import { view as commandMenuView } from "../../ui/command-menu-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - palette: CommandMenu.Model, - name: Schema.String, - visibility: Schema.String, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ClickedCreateChannel: {}, - ClickedBack: {}, - ChangedName: { value: Schema.String }, - ChangedVisibility: { value: Schema.String }, - GotPaletteMessage: { message: CommandMenu.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const foldPaletteOutMessage = CommandMenu.OutMessage.match>({ - SelectedItem: () => (model) => ({ model }), - Closed: () => (model) => ({ model }), -}) - -const paletteFields = { - read: (model: Model) => Option.some(model.palette), - write: (model: Model, nextPalette: CommandMenu.Model) => - modifyFields(model, { palette: () => nextPalette }), - toParentMessage: (message: CommandMenu.Message) => Message.GotPaletteMessage({ message }), -} - -const foldPalette = Update.foldChild({ - update: CommandMenu.update, - ...paletteFields, - foldOutMessage: foldPaletteOutMessage, -}) -const openPalette = Update.foldChildStep({ update: CommandMenu.open, ...paletteFields }) -const closePalette = Update.foldChildStep({ update: CommandMenu.close, ...paletteFields }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Command menu form", [ - gallerySection(h, "Command menu form", [ - button( - h, - { - intent: "outline", - attributes: [h.OnClick(Message.ClickedCreateChannel())], - interaction: { wiring: interaction.wiring(model), target: "create-channel" }, - }, - ["Create channel"], - ), - h.submodel({ - slotId: "palette", - model: model.palette, - view: commandMenuView, - viewInputs: { - content: () => [], - toFormPage: (closeAttributes) => [ - commandMenuFormContainer(h, [ - commandMenuFormHeader(h, { - title: "Create channel", - subtitle: "Channels are where your team talks", - backAttributes: [h.OnClick(Message.ClickedBack())], - closeAttributes, - }), - commandMenuFormBody( - h, - [ - commandMenuFormField(h, { label: "Name" }, [ - commandMenuInput(h, { - icon: IconHashtag(h, { className: "size-4" }), - attributes: [ - h.Attribute("aria-label", "Name"), - h.Attribute("placeholder", "e.g. design-reviews"), - h.Value(model.name), - h.OnInput((value) => Message.ChangedName({ value })), - ], - }), - ]), - commandMenuFormField( - h, - { - label: "Visibility", - error: "Private channels need at least one member.", - }, - [ - commandMenuToggle(h, { - name: "visibility", - value: model.visibility, - options: [ - { - value: "public", - label: "Public", - icon: IconHashtag(h, { className: "size-4" }), - }, - { - value: "private", - label: "Private", - icon: IconLock(h, { className: "size-4" }), - }, - ], - onChange: (value) => Message.ChangedVisibility({ value }), - }), - ], - ), - ], - "space-y-3", - ), - commandMenuFormFooter(h, [ - h.span([], [h.kbd([], ["↵"]), " to create"]), - button(h, { size: "xs" }, ["Create"]), - ]), - ]), - ], - }, - toParentMessage: (message) => Message.GotPaletteMessage({ message }), - }), - ]), - ]) - -export const gallery = defineGallery("Command menu form", { - Model, - init: () => ({ - model: { - palette: CommandMenu.init({ id: "create", sections: [] }), - name: "", - visibility: "public", - interaction: Interaction.init(), - }, - }), - update: (model, message) => - Message.match>(message, { - ClickedCreateChannel: () => openPalette(model), - ClickedBack: () => closePalette(model), - ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value }) }), - ChangedVisibility: ({ value }) => ({ model: modifyFields(model, { visibility: () => value }) }), - GotPaletteMessage: ({ message }) => foldPalette(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }), - subscriptions: interaction.subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/command-menu.ts b/apps/web-foldkit/src/gallery/entries/command-menu.ts deleted file mode 100644 index d05d0ed3d..000000000 --- a/apps/web-foldkit/src/gallery/entries/command-menu.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconGear, IconHashtag, IconMoon, IconPlus } from "../../icons" -import { button } from "../../ui/button" -import * as CommandMenu from "../../ui/command-menu" -import { - commandMenuDescription, - commandMenuShortcut, - view as commandMenuView, -} from "../../ui/command-menu-view" -import { menuLabel } from "../../ui/menu-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ palette: CommandMenu.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ClickedOpenCommandMenu: {}, - GotPaletteMessage: { message: CommandMenu.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldPaletteOutMessage = CommandMenu.OutMessage.match>({ - SelectedItem: () => (model) => ({ model }), - Closed: () => (model) => ({ model }), -}) - -const paletteFields = { - read: (model: Model) => Option.some(model.palette), - write: (model: Model, nextPalette: CommandMenu.Model) => - modifyFields(model, { palette: () => nextPalette }), - toParentMessage: (message: CommandMenu.Message) => Message.GotPaletteMessage({ message }), -} - -const foldPalette = Update.foldChild({ - update: CommandMenu.update, - ...paletteFields, - foldOutMessage: foldPaletteOutMessage, -}) -const openPalette = Update.foldChildStep({ update: CommandMenu.open, ...paletteFields }) - -// VIEW - -const ID = "palette" - -const view = (model: Model, h: HtmlBuilder) => { - const label = (key: string, text: string) => menuLabel(h, ID, key, text) - const contentByKey: Record ReadonlyArray> = { - general: () => [IconHashtag(h), label("general", "general")], - design: () => [ - IconHashtag(h), - label("design", "design"), - commandMenuDescription(h, ID, "design", "12 members"), - ], - create: () => [ - IconPlus(h), - label("create", "Create channel"), - commandMenuShortcut(h, ID, "create", "⌘N"), - ], - appearance: () => [IconMoon(h), label("appearance", "Change appearance")], - settings: () => [IconGear(h), label("settings", "Settings")], - } - return galleryFrame(h, "Command menu", [ - gallerySection(h, "Command menu", [ - button(h, { intent: "outline", attributes: [h.OnClick(Message.ClickedOpenCommandMenu())] }, [ - "Open command menu", - ]), - h.submodel({ - slotId: "palette", - model: model.palette, - view: commandMenuView, - viewInputs: { - content: (key) => contentByKey[key]?.() ?? [], - placeholder: "Where would you like to go?", - }, - toParentMessage: (message) => Message.GotPaletteMessage({ message }), - }), - ]), - ]) -} - -export const gallery = defineGallery("Command menu", { - Model, - init: () => ({ - model: { - palette: CommandMenu.init({ - id: ID, - sections: [ - CommandMenu.section("Recent", [ - CommandMenu.item("general", "general"), - CommandMenu.item("design", "design", true), - ]), - CommandMenu.section("Quick Actions", [ - CommandMenu.item("create", "create channel", true), - CommandMenu.item("appearance", "appearance theme"), - CommandMenu.item("settings", "settings"), - ]), - ], - }), - }, - }), - update: (model, message) => - Message.match>(message, { - ClickedOpenCommandMenu: () => openPalette(model), - GotPaletteMessage: ({ message }) => foldPalette(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/composer.ts b/apps/web-foldkit/src/gallery/entries/composer.ts deleted file mode 100644 index 2747f914e..000000000 --- a/apps/web-foldkit/src/gallery/entries/composer.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { and, eq } from "@tanstack/db" -import { Effect, Schema, Stream } from "effect" -import { Command, Subscription, type Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { channelCollection } from "~/db/collections" -import * as Composer from "../../composer/composer" -import { mentionMembersStream, presenceStream } from "../../composer/data" -import * as ComposerUpdate from "../../composer/update" -import * as ComposerView from "../../composer/view" -import { liveQueryStream } from "../../data/live-query" -import { HazelRpc } from "../../rpc" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -/** Mirrors `apps/web/src/dev-gallery/entries/composer.tsx`: the channel composer for #general. */ - -// MODEL - -const Model = Schema.Struct({ - organizationId: Schema.NullOr(Schema.String), - channelId: Schema.NullOr(Schema.String), - composer: Composer.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - SucceededFetchCurrentUser: { organizationId: Schema.NullOr(Schema.String) }, - FailedFetchCurrentUser: { reason: Schema.String }, - UpdatedChannel: { channelId: Schema.NullOr(Schema.String) }, - GotComposerMessage: { message: Composer.Message }, -}) -type Message = typeof Message.Type - -// COMMAND - -const FetchCurrentUser = Command.define("FetchCurrentUser", { - messages: [Message.SucceededFetchCurrentUser, Message.FailedFetchCurrentUser], - execute: Effect.gen(function* () { - const client = yield* HazelRpc - const user = yield* client("user.me", undefined) - return Message.SucceededFetchCurrentUser({ organizationId: user.organizationId ?? null }) - }).pipe( - Effect.catch((error) => Effect.succeed(Message.FailedFetchCurrentUser({ reason: String(error) }))), - ), -}) - -// UPDATE - -type Return = Update.Return - -const update = (model: Model, message: Message): Return => - Message.match(message, { - SucceededFetchCurrentUser: ({ organizationId }) => ({ model: { ...model, organizationId } }), - FailedFetchCurrentUser: () => ({ model }), - UpdatedChannel: ({ channelId }) => ({ model: { ...model, channelId } }), - GotComposerMessage: ({ message: composerMessage }) => { - const result = ComposerUpdate.update(model.composer, composerMessage) - return { - model: { ...model, composer: result.model }, - commands: Command.mapMessages(result.commands ?? [], (child) => - Message.GotComposerMessage({ message: child }), - ), - } - }, - }) - -// SUBSCRIPTION - -const toComposer = (message: Composer.Message) => Message.GotComposerMessage({ message }) - -const subscriptions = Subscription.make()((entry) => ({ - channel: entry( - { organizationId: Schema.NullOr(Schema.String) }, - { - modelToDependencies: (model) => ({ organizationId: model.organizationId }), - dependenciesToStream: ({ organizationId }) => - organizationId === null - ? Stream.empty - : liveQueryStream<{ id: string }, Message>( - (q) => - q - .from({ channel: channelCollection }) - .where(({ channel }) => - and( - eq(channel.organizationId, organizationId), - eq(channel.name, "general"), - ), - ) - .findOne(), - (rows) => Message.UpdatedChannel({ channelId: rows[0]?.id ?? null }), - ), - }, - ), - mentionMembers: entry( - { channelId: Schema.NullOr(Schema.String) }, - { - modelToDependencies: (model) => ({ channelId: model.channelId }), - dependenciesToStream: ({ channelId }) => - channelId === null - ? Stream.empty - : mentionMembersStream(channelId, (members) => - toComposer(Composer.Message.UpdatedMentionMembers({ members })), - ), - }, - ), - presence: Subscription.persistentEntry( - presenceStream((presence) => toComposer(Composer.Message.UpdatedPresence({ presence }))), - ), -})) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Composer", [ - gallerySection(h, "Channel composer", [ - h.div( - [h.Class("w-[640px] pt-80")], - model.organizationId !== null && model.channelId !== null - ? [ - h.submodel({ - slotId: "composer", - model: model.composer, - view: ComposerView.view, - toParentMessage: toComposer, - }), - ] - : [], - ), - ]), - ]) - -export const gallery = defineGallery("Composer", { - Model, - init: () => ({ - model: { organizationId: null, channelId: null, composer: Composer.init("gallery-composer") }, - commands: [FetchCurrentUser()], - }), - update, - view, - subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/context-menu.ts b/apps/web-foldkit/src/gallery/entries/context-menu.ts deleted file mode 100644 index 3b1862582..000000000 --- a/apps/web-foldkit/src/gallery/entries/context-menu.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { twMerge } from "tailwind-merge" -import { contextMenuTriggerBase } from "~/components/ui/context-menu.styles" -import { IconCopy, IconReply, IconTrash } from "../../icons" -import * as Menu from "../../ui/menu" -import { contextMenuView, menuLabel } from "../../ui/menu-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ message: Menu.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotMessageMenuMessage: { message: Menu.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldMenuOutMessage = Menu.OutMessage.match>({ - SelectedItem: () => (model) => ({ model }), - ActivatedLink: () => (model) => ({ model }), -}) - -const foldMessageMenu = Update.foldChild({ - update: Menu.update, - read: (model: Model) => Option.some(model.message), - write: (model, nextMenu) => modifyFields(model, { message: () => nextMenu }), - toParentMessage: (message) => Message.GotMessageMenuMessage({ message }), - foldOutMessage: foldMenuOutMessage, -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const label = (key: string, text: string) => menuLabel(h, "message", key, text) - const contentByKey: Record ReadonlyArray> = { - reply: () => [IconReply(h, { className: "size-4" }), label("reply", "Reply")], - copy: () => [IconCopy(h, { className: "size-4" }), label("copy", "Copy text")], - delete: () => [IconTrash(h, { className: "size-4" }), label("delete", "Delete message")], - } - return galleryFrame(h, "Context menu", [ - gallerySection(h, "Context menu", [ - h.submodel({ - slotId: "message", - model: model.message, - view: contextMenuView, - viewInputs: { - toTrigger: (attributes, overlay) => - h.div( - [ - ...attributes, - h.Class( - twMerge( - contextMenuTriggerBase, - "flex h-32 w-80 items-center justify-center rounded-lg border border-dashed text-muted-fg text-sm", - ), - ), - ], - ["Right-click this message", overlay], - ), - content: (key) => contentByKey[key]?.() ?? [], - className: "min-w-56", - }, - toParentMessage: (message) => Message.GotMessageMenuMessage({ message }), - }), - ]), - ]) -} - -export const gallery = defineGallery("Context menu", { - Model, - init: () => ({ - model: { - message: Menu.init({ - id: "message", - anchor: "Pointer", - entries: [ - Menu.item("reply"), - Menu.item("copy"), - Menu.separator, - Menu.item("delete", { intent: "Danger" }), - ], - }), - }, - }), - update: (model, message) => - Message.match>(message, { - GotMessageMenuMessage: ({ message }) => foldMessageMenu(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/date-field.ts b/apps/web-foldkit/src/gallery/entries/date-field.ts deleted file mode 100644 index 130e5e0c6..000000000 --- a/apps/web-foldkit/src/gallery/entries/date-field.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Interaction from "../../ui/aria/interaction" -import { dateField } from "../../ui/date-field" -import * as D from "../../ui/calendar-date" -import * as Segments from "../../ui/date-segments" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const FieldKey = Schema.Literals(["start", "due", "disabled", "invalid"]) -type FieldKey = typeof FieldKey.Type - -const Model = Schema.Struct({ - start: Segments.Model, - due: Segments.Model, - disabled: Segments.Model, - invalid: Segments.Model, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotDateFieldMessage: { fieldId: FieldKey, message: Segments.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -const toFieldMessage = - (field: FieldKey) => - (message: Segments.Message): Message => - Message.GotDateFieldMessage({ fieldId: field, message }) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotDateFieldMessage: ({ fieldId: field, message }) => - Update.foldChild({ - update: Segments.update, - read: (current: Model) => Option.some(current[field]), - write: (current: Model, next: Segments.Model): Model => ({ ...current, [field]: next }), - toParentMessage: toFieldMessage(field), - })(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const common = (field: FieldKey) => ({ - model: model[field], - toParentMessage: toFieldMessage(field), - interaction: interaction.wiring(model), - }) - return galleryFrame(h, "Date field", [ - gallerySection(h, "Fields", [ - ...dateField(h, common("start"), (f) => [f.label(["Start date"]), f.dateInput()]), - ...dateField(h, common("due"), (f) => [ - f.label(["Due date"]), - f.dateInput(), - f.description(["When the task is due."]), - ]), - ]), - gallerySection(h, "States", [ - ...dateField(h, common("disabled"), (f) => [f.label(["Disabled"]), f.dateInput()]), - ...dateField(h, common("invalid"), (f) => [ - f.label(["Invalid"]), - f.dateInput(), - f.fieldError(["Pick a weekday."]), - ]), - ]), - ]) -} - -export const gallery = defineGallery("Date field", { - Model, - init: () => { - const today = D.today() - return { - model: { - start: Segments.init({ id: "start", kind: "date", today }), - due: Segments.init({ id: "due", kind: "date", today, value: "2026-10-07" }), - disabled: Segments.init({ - id: "disabled", - kind: "date", - today, - value: "2026-01-15", - isDisabled: true, - }), - invalid: Segments.init({ - id: "invalid", - kind: "date", - today, - value: "2026-02-28", - isInvalid: true, - }), - interaction: Interaction.init(), - }, - } - }, - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/date-picker.ts b/apps/web-foldkit/src/gallery/entries/date-picker.ts deleted file mode 100644 index eeb6b1d16..000000000 --- a/apps/web-foldkit/src/gallery/entries/date-picker.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as D from "../../ui/calendar-date" -import * as DatePicker from "../../ui/date-picker" -import { datePickerHidden, view as datePickerView } from "../../ui/date-picker-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Slot = Schema.Literals(["status", "due"]) -type Slot = typeof Slot.Type - -const Model = Schema.Struct({ status: DatePicker.Model, due: DatePicker.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotPickerMessage: { slotId: Slot, message: DatePicker.Message }, -}) -type Message = typeof Message.Type - -const toMessage = (slot: Slot) => (message: DatePicker.Message) => - Message.GotPickerMessage({ slotId: slot, message }) - -// UPDATE - -const fold = (slot: Slot) => - Update.foldChild({ - update: DatePicker.update, - read: (model: Model) => Option.some(model[slot]), - write: (model: Model, next: DatePicker.Model): Model => ({ ...model, [slot]: next }), - toParentMessage: toMessage(slot), - foldOutMessage: () => (model: Model) => ({ model }), - }) - -const lift = (slot: Slot) => - Subscription.lift(DatePicker.subscriptions)({ - read: (model) => Option.some(model[slot]), - toParentMessage: toMessage(slot), - }) - -const subscriptions = Subscription.aggregate()(lift("status"), { - dueModality: lift("due").modality, - duePointerRelease: lift("due").pointerRelease, -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const picker = (slot: Slot, viewInputs: { label?: string; ariaLabel?: string }) => [ - h.submodel({ - slotId: slot, - model: model[slot], - view: datePickerView, - viewInputs: { ...viewInputs, className: "w-64" }, - toParentMessage: toMessage(slot), - }), - ...datePickerHidden(h, model[slot]), - ] - return galleryFrame(h, "DatePicker", [ - gallerySection(h, "With a value", picker("status", { ariaLabel: "Clear status after" })), - gallerySection(h, "With a label, empty", picker("due", { label: "Due date" })), - ]) -} - -export const gallery = defineGallery("DatePicker", { - Model, - init: () => { - const today = D.today() - return { - model: { - status: DatePicker.init({ id: "status", today, value: "2026-03-18" }), - due: DatePicker.init({ id: "due", today }), - }, - } - }, - update: (model, message) => - Message.match>(message, { - GotPickerMessage: ({ slotId: slot, message }) => fold(slot)(model, message), - }), - subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/dialog.ts b/apps/web-foldkit/src/gallery/entries/dialog.ts deleted file mode 100644 index 6fc017a58..000000000 --- a/apps/web-foldkit/src/gallery/entries/dialog.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { button } from "../../ui/button" -import { - dialogBody, - dialogClose, - dialogDescription, - dialogFooter, - dialogHeader, - dialogTitle, -} from "../../ui/dialog" -import * as Modal from "../../ui/modal" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ rename: Modal.Model, remove: Modal.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotRenameMessage: { message: Modal.Message }, - GotRemoveMessage: { message: Modal.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldRename = Update.foldChild({ - update: Modal.update, - read: (model: Model) => Option.some(model.rename), - write: (model, nextRename) => modifyFields(model, { rename: () => nextRename }), - toParentMessage: (message) => Message.GotRenameMessage({ message }), -}) - -const foldRemove = Update.foldChild({ - update: Modal.update, - read: (model: Model) => Option.some(model.remove), - write: (model, nextRemove) => modifyFields(model, { remove: () => nextRemove }), - toParentMessage: (message) => Message.GotRemoveMessage({ message }), -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Dialog", [ - gallerySection(h, "Dialog", [ - h.submodel({ - slotId: "rename", - model: model.rename, - view: Modal.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "outline", attributes }, ["Open dialog", overlay]), - toContent: (closeAttributes) => [ - dialogHeader(h, {}, [ - dialogTitle(h, { id: Modal.titleId("rename") }, "Rename thread"), - dialogDescription(h, "Give this thread a name everyone will recognise."), - ]), - dialogBody(h, [ - h.p( - [h.Class("text-sm/6")], - [ - "Thread names show up in the sidebar and in search results. You can change the name again at any time.", - ], - ), - ]), - dialogFooter(h, [ - dialogClose(h, closeAttributes, ["Cancel"]), - button(h, {}, ["Save"]), - ]), - ], - }, - toParentMessage: (message) => Message.GotRenameMessage({ message }), - }), - ]), - gallerySection(h, "Alert dialog", [ - h.submodel({ - slotId: "remove", - model: model.remove, - view: Modal.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "danger", attributes }, ["Delete channel", overlay]), - toContent: (closeAttributes) => [ - dialogHeader(h, { - title: { id: Modal.titleId("remove"), text: "Delete channel?" }, - description: "Messages in this channel will be removed for everyone.", - }), - dialogFooter(h, [ - dialogClose(h, closeAttributes, ["Cancel"]), - button(h, { intent: "danger" }, ["Delete"]), - ]), - ], - role: "alertdialog", - size: "md", - }, - toParentMessage: (message) => Message.GotRemoveMessage({ message }), - }), - ]), - ]) - -export const gallery = defineGallery("Dialog", { - Model, - init: () => ({ model: { rename: Modal.init("rename"), remove: Modal.init("remove") } }), - update: (model, message) => - Message.match>(message, { - GotRenameMessage: ({ message }) => foldRename(model, message), - GotRemoveMessage: ({ message }) => foldRemove(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/dropdown.ts b/apps/web-foldkit/src/gallery/entries/dropdown.ts deleted file mode 100644 index 590ff0e72..000000000 --- a/apps/web-foldkit/src/gallery/entries/dropdown.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconCopy, IconEdit, IconTrash } from "../../icons" -import * as ListBox from "../../ui/list-box" -import { - dropdownKeyboard, - listBoxDescription, - listBoxLabel, - view as listBoxView, - withFocusScope, -} from "../../ui/list-box-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -/** `DropdownSection`, `DropdownItem` and friends inside a ListBox, as `dropdown.tsx` is used. */ - -// MODEL - -const Model = Schema.Struct({ actions: ListBox.Model }) -type Model = typeof Model.Type - -const dropdownItem = (key: string, textValue: string, options: Parameters[2] = {}) => - ListBox.item(key, textValue, { ...options, flavor: "Dropdown" }) - -const init = (): Model => ({ - actions: ListBox.init({ - id: "actions", - entries: [ - ListBox.section("Message", [dropdownItem("edit", "Edit"), dropdownItem("copy", "Copy text")], { - isListBoxSection: false, - }), - ListBox.separator, - ListBox.section( - "Danger zone", - [ - dropdownItem("delete", "Delete", { intent: "danger", hasDescription: true }), - dropdownItem("archive", "Archive", { intent: "warning", isDisabled: true }), - ], - { isListBoxSection: false }, - ), - ], - selectionMode: "single", - selectedKeys: ["edit"], - }), -}) - -// MESSAGE - -const Message = defineMessageUnion({ - GotActionsMessage: { message: ListBox.Message }, -}) -type Message = typeof Message.Type - -const toActionsMessage = (message: ListBox.Message) => Message.GotActionsMessage({ message }) - -// UPDATE - -const foldActions = Update.foldChild({ - update: ListBox.update, - read: (model: Model) => Option.some(model.actions), - write: (_model: Model, actions: ListBox.Model): Model => ({ actions }), - toParentMessage: toActionsMessage, - foldOutMessage: () => (model: Model) => ({ model }), -}) - -const subscriptions = Subscription.aggregate()({ - actionsRelease: Subscription.lift(ListBox.subscriptions)({ - read: (model) => Option.some(model.actions), - toParentMessage: toActionsMessage, - }).pointerRelease, -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const icon = { attributes: { "data-slot": "icon" } } - const label = (key: string, text: string) => listBoxLabel(h, "actions", key, text) - const content: Record ReadonlyArray> = { - edit: () => [IconEdit(h, icon), label("edit", "Edit"), dropdownKeyboard(h, "E")], - copy: () => [IconCopy(h, icon), label("copy", "Copy text"), dropdownKeyboard(h, "⌘C")], - delete: () => [ - IconTrash(h, icon), - label("delete", "Delete"), - listBoxDescription(h, "actions", "delete", "Removes it for everyone"), - ], - archive: () => [label("archive", "Archive")], - } - return galleryFrame(h, "Dropdown", [ - gallerySection( - h, - "Items and sections", - withFocusScope( - h, - h.submodel({ - slotId: "actions", - model: model.actions, - view: listBoxView, - viewInputs: { - ariaLabel: "Message actions", - className: "w-72", - content: (key) => content[key]?.() ?? [], - }, - toParentMessage: toActionsMessage, - }), - ), - ), - ]) -} - -export const gallery = defineGallery("Dropdown", { - Model, - init: () => ({ model: init() }), - update: (model, message) => - Message.match>(message, { - GotActionsMessage: ({ message }) => foldActions(model, message), - }), - subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/empty-state.ts b/apps/web-foldkit/src/gallery/entries/empty-state.ts deleted file mode 100644 index ec9b6878d..000000000 --- a/apps/web-foldkit/src/gallery/entries/empty-state.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconFolder } from "../../icons" -import { button } from "../../ui/button" -import { emptyState } from "../../ui/empty-state" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => { - const folder = (className: string) => IconFolder(h, { className }) - return galleryFrame(h, "Empty state", [ - gallerySection(h, "Full", [ - emptyState(h, { - icon: folder, - title: "No channels yet", - description: "Create a channel to start talking with your team.", - action: button(h, { size: "sm" }, ["Create channel"]), - }), - ]), - gallerySection(h, "Title only", [emptyState(h, { title: "Nothing here" })]), - gallerySection(h, "Long description", [ - emptyState(h, { - icon: folder, - title: "No results match your filters", - description: - "Try removing some filters or searching with different keywords. Results include messages, files and channels from every workspace you belong to.", - className: "py-8", - }), - ]), - ]) -} - -export const gallery = defineGallery("Empty state", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/field-validation-state.ts b/apps/web-foldkit/src/gallery/entries/field-validation-state.ts deleted file mode 100644 index 903247351..000000000 --- a/apps/web-foldkit/src/gallery/entries/field-validation-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { fieldValidationState, type ValidationState } from "../../ui/field-validation-state" -import { input } from "../../ui/input" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGalleryEvent: {} }) -type Message = typeof Message.Type - -const states: ReadonlyArray = ["idle", "validating", "valid", "invalid"] - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Field validation state", [ - gallerySection( - h, - "States", - states.map((state) => - h.div( - [h.Class("relative w-56")], - [ - input(h, { defaultValue: state, attributes: [h.AriaLabel(`Validation ${state}`)] }), - fieldValidationState(h, { state }), - ], - ), - ), - ), - ]) - -export const gallery = defineGallery("Field validation state", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/field.ts b/apps/web-foldkit/src/gallery/entries/field.ts deleted file mode 100644 index 54774605b..000000000 --- a/apps/web-foldkit/src/gallery/entries/field.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { description, fieldErrors, fieldset, label, legend } from "../../ui/field" -import { textField } from "../../ui/text-field" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ displayName: Schema.String, interaction: Interaction.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - UpdatedDisplayName: { value: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - UpdatedDisplayName: ({ value }) => ({ model: modifyFields(model, { displayName: () => value }) }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Field", [ - gallerySection(h, "Parts", [ - h.div( - [h.Class("flex w-72 flex-col")], - [label(h, {}, ["Standalone label"]), description(h, {}, ["Standalone description text."])], - ), - h.div( - [h.Class("w-72")], - [ - fieldErrors(h, { - errors: [ - { message: "Name is required." }, - { message: "Must be at least 3 characters." }, - ], - }), - ], - ), - ]), - gallerySection(h, "Fieldset", [ - h.div( - [h.Class("w-96")], - [ - fieldset(h, {}, [ - legend(h, {}, ["Profile"]), - h.p( - [h.DataAttribute("slot", "text"), h.Class("text-muted-fg text-sm")], - ["How others see you."], - ), - textField( - h, - { - id: "display-name", - value: model.displayName, - onInput: (value) => Message.UpdatedDisplayName({ value }), - interaction: interaction.wiring(model), - }, - (f) => [f.label(["Display name"]), f.input({ placeholder: "Ada" })], - ), - ]), - ], - ), - ]), - ]) - -export const gallery = defineGallery("Field", { - Model, - init: () => ({ model: { displayName: "", interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/form-error-summary.ts b/apps/web-foldkit/src/gallery/entries/form-error-summary.ts deleted file mode 100644 index effc9a3f6..000000000 --- a/apps/web-foldkit/src/gallery/entries/form-error-summary.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { formErrorSummary } from "../../ui/form-error-summary" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGalleryEvent: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Form error summary", [ - gallerySection(h, "Default title", [ - h.div( - [h.Class("w-96")], - [ - formErrorSummary(h, { - errors: [ - { field: "Name", message: "Name is required." }, - { field: "Email", message: "Enter a valid email address." }, - ], - }), - ], - ), - ]), - gallerySection(h, "Custom title", [ - h.div( - [h.Class("w-96")], - [ - formErrorSummary(h, { - title: "Could not save the channel", - errors: [{ field: "Slug", message: "This slug is taken." }], - }), - ], - ), - ]), - gallerySection(h, "No errors", [formErrorSummary(h, { errors: [] })]), - ]) - -export const gallery = defineGallery("Form error summary", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/input-otp.ts b/apps/web-foldkit/src/gallery/entries/input-otp.ts deleted file mode 100644 index a554982de..000000000 --- a/apps/web-foldkit/src/gallery/entries/input-otp.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as InputOtp from "../../ui/input-otp" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const OtpKey = Schema.Literals(["code", "pin", "disabled"]) -type OtpKey = typeof OtpKey.Type - -const Model = Schema.Struct({ code: InputOtp.Model, pin: InputOtp.Model, disabled: InputOtp.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotInputOtpMessage: { otpId: OtpKey, message: InputOtp.Message }, -}) -type Message = typeof Message.Type - -const toOtpMessage = - (otp: OtpKey) => - (message: InputOtp.Message): Message => - Message.GotInputOtpMessage({ otpId: otp, message }) - -// INIT - -const init = (): Update.Return => - Update.foldChildInits( - { - code: InputOtp.init({ id: "otp-code", maxLength: 6 }), - pin: InputOtp.init({ id: "otp-pin", maxLength: 4, value: "42" }), - disabled: InputOtp.init({ id: "otp-disabled", maxLength: 4, value: "1234", isDisabled: true }), - }, - { - toParentModel: ({ code, pin, disabled }): Model => ({ code, pin, disabled }), - folds: { - code: { toParentMessage: toOtpMessage("code") }, - pin: { toParentMessage: toOtpMessage("pin") }, - disabled: { toParentMessage: toOtpMessage("disabled") }, - }, - }, - ) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotInputOtpMessage: ({ otpId: otp, message }) => - Update.foldChild({ - update: InputOtp.update, - read: (current: Model) => Option.some(current[otp]), - write: (current: Model, next: InputOtp.Model): Model => ({ ...current, [otp]: next }), - toParentMessage: toOtpMessage(otp), - })(model, message), - }) - -const liftOtp = (otp: OtpKey) => - Subscription.lift(InputOtp.subscriptions)({ - read: (model) => Option.some(model[otp]), - toParentMessage: toOtpMessage(otp), - }).selection - -const subscriptions = Subscription.aggregate()( - { codeSelection: liftOtp("code") }, - { pinSelection: liftOtp("pin") }, - { disabledSelection: liftOtp("disabled") }, -) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const otp = ( - key: OtpKey, - ariaLabel: string, - slots: (parts: InputOtp.InputOtpParts) => ReturnType[], - ) => InputOtp.inputOtp(h, { model: model[key], toParentMessage: toOtpMessage(key), ariaLabel }, slots) - return galleryFrame(h, "Input OTP", [ - gallerySection(h, "Six digits", [ - otp("code", "Verification code", (parts) => [ - parts.group([parts.slot(0), parts.slot(1), parts.slot(2)]), - parts.separator(), - parts.group([parts.slot(3), parts.slot(4), parts.slot(5)]), - ]), - ]), - gallerySection(h, "Prefilled", [ - otp("pin", "PIN", (parts) => [parts.group([0, 1, 2, 3].map((index) => parts.slot(index)))]), - ]), - gallerySection(h, "Disabled", [ - otp("disabled", "Disabled code", (parts) => [ - parts.group([0, 1, 2, 3].map((index) => parts.slot(index))), - ]), - ]), - ]) -} - -export const gallery = defineGallery("Input OTP", { - Model, - init, - update, - view, - subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/input.ts b/apps/web-foldkit/src/gallery/entries/input.ts deleted file mode 100644 index 11951c1b8..000000000 --- a/apps/web-foldkit/src/gallery/entries/input.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconMagnifier3 } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import { input, inputGroup } from "../../ui/input" -import { loader } from "../../ui/loader" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ interaction: Interaction.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - const at = (target: string) => ({ wiring, target }) - const labelled = (label: string) => [h.AriaLabel(label)] - const box = (child: ReturnType) => h.div([h.Class("w-64")], [child]) - return galleryFrame(h, "Input", [ - gallerySection(h, "Plain", [ - box( - input(h, { - placeholder: "Placeholder", - interaction: at("name"), - attributes: labelled("Name"), - }), - ), - box( - input(h, { - defaultValue: "Ada Lovelace", - interaction: at("filled"), - attributes: labelled("Filled"), - }), - ), - box( - input(h, { - defaultValue: "Disabled", - isDisabled: true, - interaction: at("disabled"), - attributes: labelled("Disabled"), - }), - ), - box( - input(h, { - defaultValue: "Invalid", - isInvalid: true, - interaction: at("invalid"), - attributes: labelled("Invalid"), - }), - ), - ]), - gallerySection(h, "Groups", [ - box( - inputGroup(h, { interaction: at("group search") }, [ - IconMagnifier3(h), - input(h, { - placeholder: "Search", - interaction: at("search"), - attributes: labelled("Leading icon"), - }), - ]), - ), - box( - inputGroup(h, { interaction: at("group loader") }, [ - input(h, { - placeholder: "Loading", - interaction: at("loading"), - attributes: labelled("Trailing loader"), - }), - loader(h), - ]), - ), - box( - inputGroup(h, { interaction: at("group prefix") }, [ - h.span([h.DataAttribute("slot", "text")], ["https://"]), - input(h, { - placeholder: "example.com", - interaction: at("prefix"), - attributes: labelled("Prefix text"), - }), - ]), - ), - box( - inputGroup(h, { interaction: at("group button") }, [ - input(h, { - placeholder: "Invite by email", - interaction: at("invite"), - attributes: labelled("With button"), - }), - button(h, { intent: "outline", interaction: at("send") }, ["Send"]), - ]), - ), - ]), - ]) -} - -export const gallery = defineGallery("Input", { - Model, - init: () => ({ model: { interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/link.ts b/apps/web-foldkit/src/gallery/entries/link.ts deleted file mode 100644 index 4b53c924b..000000000 --- a/apps/web-foldkit/src/gallery/entries/link.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Interaction from "../../ui/aria/interaction" -import { link, type LinkOptions } from "../../ui/link" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ interaction: Interaction.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - const demo = (target: string, options: LinkOptions = {}) => ({ - ...options, - interaction: { wiring, target }, - }) - return galleryFrame(h, "Link", [ - gallerySection(h, "With href", [ - link(h, demo("docs", { href: "/dev/gallery/link#docs" }), ["Documentation"]), - link(h, demo("primary", { href: "/dev/gallery/link#primary", className: "text-primary" }), [ - "Primary text", - ]), - link( - h, - demo("underlined", { - href: "/dev/gallery/link#underlined", - className: "underline underline-offset-4", - }), - ["Underlined"], - ), - ]), - gallerySection(h, "Without href", [link(h, demo("pressable"), ["Pressable text"])]), - gallerySection(h, "Disabled", [ - link(h, demo("disabled href", { href: "/dev/gallery/link#disabled", isDisabled: true }), [ - "Disabled with href", - ]), - link(h, demo("disabled", { isDisabled: true }), ["Disabled without href"]), - ]), - ]) -} - -export const gallery = defineGallery("Link", { - Model, - init: () => ({ model: { interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/list-box.ts b/apps/web-foldkit/src/gallery/entries/list-box.ts deleted file mode 100644 index c72b2332b..000000000 --- a/apps/web-foldkit/src/gallery/entries/list-box.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as ListBox from "../../ui/list-box" -import { listBoxDescription, listBoxLabel, view as listBoxView, withFocusScope } from "../../ui/list-box-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Slot = Schema.Literals(["theme", "notify", "jump"]) -type Slot = typeof Slot.Type - -const Model = Schema.Struct({ theme: ListBox.Model, notify: ListBox.Model, jump: ListBox.Model }) -type Model = typeof Model.Type - -const notifyItems: ReadonlyArray = [ - ["mentions", "Mentions", "When someone @mentions you"], - ["replies", "Replies", "Replies to your threads"], - ["reactions", "Reactions", "Emoji on your messages"], - ["all", "All messages", "Every new message in the channel"], -] - -const init = (): Model => ({ - theme: ListBox.init({ - id: "theme", - entries: [ - ListBox.entry(ListBox.item("light", "Light")), - ListBox.entry(ListBox.item("dark", "Dark")), - ListBox.entry(ListBox.item("system", "System")), - ], - selectionMode: "single", - selectedKeys: ["system"], - }), - notify: ListBox.init({ - id: "notify", - entries: notifyItems.map(([key, label]) => - ListBox.entry(ListBox.item(key, label, { hasDescription: true, isDisabled: key === "all" })), - ), - selectionMode: "multiple", - selectedKeys: ["mentions", "replies"], - }), - jump: ListBox.init({ - id: "jump", - entries: [ - ListBox.section("Channels", [ - ListBox.item("general", "general"), - ListBox.item("design", "design"), - ]), - ListBox.section("Direct messages", [ - ListBox.item("grace", "Grace Hopper"), - ListBox.item("alan", "Alan Turing"), - ]), - ], - selectionMode: "single", - }), -}) - -// MESSAGE - -const Message = defineMessageUnion({ - GotListBoxMessage: { slotId: Slot, message: ListBox.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const fold = (slot: Slot) => - Update.foldChild({ - update: ListBox.update, - read: (model: Model) => Option.some(model[slot]), - write: (model: Model, next: ListBox.Model): Model => ({ ...model, [slot]: next }), - toParentMessage: (message: ListBox.Message) => Message.GotListBoxMessage({ slotId: slot, message }), - foldOutMessage: () => (model: Model) => ({ model }), - }) - -const lift = (slot: Slot) => - Subscription.lift(ListBox.subscriptions)({ - read: (model) => Option.some(model[slot]), - toParentMessage: (message) => Message.GotListBoxMessage({ slotId: slot, message }), - }).pointerRelease - -const subscriptions = Subscription.aggregate()( - { themeRelease: lift("theme") }, - { notifyRelease: lift("notify") }, - { jumpRelease: lift("jump") }, -) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const listBox = (slot: Slot, viewInputs: Parameters[1]) => - withFocusScope( - h, - h.submodel({ - slotId: slot, - model: model[slot], - view: listBoxView, - viewInputs, - toParentMessage: (message) => Message.GotListBoxMessage({ slotId: slot, message }), - }), - ) - const notifyContent = (key: string): ReadonlyArray => { - const [, label, description] = notifyItems.find(([candidate]) => candidate === key) ?? [key, key, ""] - return [listBoxLabel(h, "notify", key, label), listBoxDescription(h, "notify", key, description)] - } - return galleryFrame(h, "ListBox", [ - gallerySection(h, "Single selection", listBox("theme", { ariaLabel: "Theme", className: "w-64" })), - gallerySection( - h, - "Multiple selection with descriptions", - listBox("notify", { ariaLabel: "Notify me about", className: "w-72", content: notifyContent }), - ), - gallerySection(h, "Sections", listBox("jump", { ariaLabel: "Jump to", className: "w-64" })), - ]) -} - -export const gallery = defineGallery("ListBox", { - Model, - init: () => ({ model: init() }), - update: (model, message) => - Message.match>(message, { - GotListBoxMessage: ({ slotId: slot, message }) => fold(slot)(model, message), - }), - subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/loader.ts b/apps/web-foldkit/src/gallery/entries/loader.ts deleted file mode 100644 index e1e770aac..000000000 --- a/apps/web-foldkit/src/gallery/entries/loader.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { loader } from "../../ui/loader" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Loader", [ - gallerySection(h, "Ring", [ - loader(h, { variant: "ring", ariaLabel: "Loading ring" }), - loader(h, { variant: "ring", ariaLabel: "Loading large ring", className: "size-6 text-primary" }), - ]), - gallerySection(h, "Spin", [ - loader(h, { ariaLabel: "Spinning" }), - loader(h, { ariaLabel: "Spinning large", className: "size-8 text-muted-fg" }), - ]), - ]) - -export const gallery = defineGallery("Loader", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/menu.ts b/apps/web-foldkit/src/gallery/entries/menu.ts deleted file mode 100644 index 00db14e9b..000000000 --- a/apps/web-foldkit/src/gallery/entries/menu.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconDots, IconFolder, IconFolderPlus, IconLeave, IconStar, IconVolumeMute } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import * as Menu from "../../ui/menu" -import { - menuDescription, - menuLabel, - menuShortcut, - menuTriggerClassName, - view as menuView, -} from "../../ui/menu-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - actions: Menu.Model, - density: Menu.Model, - more: Menu.Model, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -const actions = Menu.init({ - id: "actions", - entries: [ - Menu.section("Channel", [ - Menu.item("mute", { hasDescription: true }), - Menu.item("favorite"), - Menu.item("move", { submenu: [Menu.leaf("channels"), Menu.leaf("projects")] }), - ]), - Menu.separator, - Menu.item("archive", { isDisabled: true }), - Menu.separator, - Menu.item("leave", { intent: "Danger", textValue: "Leave" }), - ], -}) - -const density = Menu.init({ - id: "density", - entries: [Menu.item("compact"), Menu.item("comfortable", { hasDescription: true })], - selectionMode: "Single", - selectedKeys: ["comfortable"], -}) - -const more = Menu.init({ - id: "more", - entries: [Menu.item("copy"), Menu.item("edit")], - placement: "right top", -}) - -// MESSAGE - -const Message = defineMessageUnion({ - GotActionsMessage: { message: Menu.Message }, - GotDensityMessage: { message: Menu.Message }, - GotMoreMessage: { message: Menu.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const foldMenuOutMessage = Menu.OutMessage.match>({ - SelectedItem: () => (model) => ({ model }), - ActivatedLink: () => (model) => ({ model }), -}) - -const foldActions = Update.foldChild({ - update: Menu.update, - read: (model: Model) => Option.some(model.actions), - write: (model, nextActions) => modifyFields(model, { actions: () => nextActions }), - toParentMessage: (message) => Message.GotActionsMessage({ message }), - foldOutMessage: foldMenuOutMessage, -}) - -const foldDensity = Update.foldChild({ - update: Menu.update, - read: (model: Model) => Option.some(model.density), - write: (model, nextDensity) => modifyFields(model, { density: () => nextDensity }), - toParentMessage: (message) => Message.GotDensityMessage({ message }), - foldOutMessage: foldMenuOutMessage, -}) - -const foldMore = Update.foldChild({ - update: Menu.update, - read: (model: Model) => Option.some(model.more), - write: (model, nextMore) => modifyFields(model, { more: () => nextMore }), - toParentMessage: (message) => Message.GotMoreMessage({ message }), - foldOutMessage: foldMenuOutMessage, -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - const actionsLabel = (key: string, text: string) => menuLabel(h, "actions", key, text) - const contentByKey: Record ReadonlyArray> = { - mute: () => [ - IconVolumeMute(h, { className: "size-4" }), - actionsLabel("mute", "Mute"), - menuShortcut(h, "actions", "mute", "⌘M"), - ], - favorite: () => [ - IconStar(h, { className: "size-4 text-muted-fg" }), - actionsLabel("favorite", "Favorite"), - ], - move: () => [IconFolderPlus(h, { className: "size-4" }), actionsLabel("move", "Move to section")], - channels: () => [actionsLabel("channels", "Channels (Default)")], - projects: () => [actionsLabel("projects", "Projects")], - archive: () => [IconFolder(h, { className: "size-4" }), actionsLabel("archive", "Archive")], - leave: () => [IconLeave(h, { className: "size-4" }), actionsLabel("leave", "Leave")], - } - - return galleryFrame(h, "Menu", [ - gallerySection(h, "Actions", [ - h.submodel({ - slotId: "actions", - model: model.actions, - view: menuView, - viewInputs: { - toTrigger: (attributes, overlay) => - button( - h, - { intent: "outline", attributes, interaction: { wiring, target: "actions" } }, - ["Actions", overlay], - ), - content: (key) => contentByKey[key]?.() ?? [], - className: "w-56", - }, - toParentMessage: (message) => Message.GotActionsMessage({ message }), - }), - ]), - gallerySection(h, "Selection", [ - h.submodel({ - slotId: "density", - model: model.density, - view: menuView, - viewInputs: { - toTrigger: (attributes, overlay) => - button( - h, - { intent: "outline", attributes, interaction: { wiring, target: "density" } }, - ["Density", overlay], - ), - content: (key) => - key === "compact" - ? [menuLabel(h, "density", key, "Compact")] - : [ - menuLabel(h, "density", key, "Comfortable"), - menuDescription(h, "density", key, "Roomier rows"), - ], - }, - toParentMessage: (message) => Message.GotDensityMessage({ message }), - }), - ]), - gallerySection(h, "Trigger", [ - h.submodel({ - slotId: "more", - model: model.more, - view: menuView, - viewInputs: { - toTrigger: (attributes, overlay) => - h.button( - [ - ...attributes, - ...Interaction.targetAttributes(h, wiring, "more"), - h.Attribute("aria-label", "More"), - h.Class(menuTriggerClassName()), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("data-slot", "menu-trigger"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), - ], - [IconDots(h, { className: "size-4" }), overlay], - ), - content: (key) => [menuLabel(h, "more", key, key === "copy" ? "Copy link" : "Edit")], - }, - toParentMessage: (message) => Message.GotMoreMessage({ message }), - }), - ]), - ]) -} - -export const gallery = defineGallery("Menu", { - Model, - init: () => ({ model: { actions, density, more, interaction: Interaction.init() } }), - update: (model, message) => - Message.match>(message, { - GotActionsMessage: ({ message }) => foldActions(model, message), - GotDensityMessage: ({ message }) => foldDensity(model, message), - GotMoreMessage: ({ message }) => foldMore(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }), - subscriptions: interaction.subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/modal.ts b/apps/web-foldkit/src/gallery/entries/modal.ts deleted file mode 100644 index aa2fc5d30..000000000 --- a/apps/web-foldkit/src/gallery/entries/modal.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Array, Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import type { ModalSize } from "~/components/ui/modal.styles" -import { button } from "../../ui/button" -import { dialogBody, dialogClose, dialogFooter, dialogHeader } from "../../ui/dialog" -import * as Modal from "../../ui/modal" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ modals: Schema.Array(Modal.Model) }) -type Model = typeof Model.Type - -const sizes: ReadonlyArray> = [ - { id: "sm", label: "Small", size: "sm" }, - { id: "xl", label: "Extra large", size: "xl" }, - { id: "fullscreen", label: "Fullscreen", size: "fullscreen" }, -] - -// MESSAGE - -const Message = defineMessageUnion({ - GotModalMessage: { id: Schema.String, message: Modal.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldModal = (id: string) => - Update.foldChild({ - update: Modal.update, - read: (model: Model) => Array.findFirst(model.modals, (modal) => modal.id === id), - write: (model, nextModal) => - modifyFields(model, { modals: Array.map((modal) => (modal.id === id ? nextModal : modal)) }), - toParentMessage: (message) => Message.GotModalMessage({ id, message }), - }) - -// VIEW - -const modalOf = (model: Model, id: string) => - Option.getOrElse( - Array.findFirst(model.modals, (modal) => modal.id === id), - () => Modal.init(id), - ) - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Modal", [ - gallerySection( - h, - "Sizes", - Array.map(sizes, (example) => - h.submodel({ - slotId: example.id, - model: modalOf(model, example.id), - view: Modal.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "outline", attributes }, [example.label, overlay]), - toContent: (closeAttributes) => [ - dialogHeader(h, { - title: { id: Modal.titleId(example.id), text: `${example.label} modal` }, - description: "Modals keep focus until they close.", - }), - dialogBody(h, [ - h.p( - [h.Class("text-sm/6")], - ["The panel width follows the size prop from sm upwards."], - ), - ]), - dialogFooter(h, [dialogClose(h, closeAttributes, ["Close"])]), - ], - size: example.size, - }, - toParentMessage: (message) => Message.GotModalMessage({ id: example.id, message }), - }), - ), - ), - gallerySection(h, "Options", [ - h.submodel({ - slotId: "blurred", - model: modalOf(model, "blurred"), - view: Modal.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "outline", attributes }, ["Blurred", overlay]), - toContent: (closeAttributes) => [ - dialogHeader(h, { - title: { id: Modal.titleId("blurred"), text: "Blurred backdrop" }, - description: "No close icon, blurred overlay.", - }), - dialogFooter(h, [dialogClose(h, closeAttributes, ["Done"])]), - ], - isBlurred: true, - closeButton: false, - }, - toParentMessage: (message) => Message.GotModalMessage({ id: "blurred", message }), - }), - ]), - ]) - -export const gallery = defineGallery("Modal", { - Model, - init: () => ({ model: { modals: Array.map(["sm", "xl", "fullscreen", "blurred"], Modal.init) } }), - update: (model, message) => - Message.match>(message, { - GotModalMessage: ({ id, message }) => foldModal(id)(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/popover.ts b/apps/web-foldkit/src/gallery/entries/popover.ts deleted file mode 100644 index cce2eb4aa..000000000 --- a/apps/web-foldkit/src/gallery/entries/popover.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import { dialogBody, dialogClose, dialogDescription, dialogHeader, dialogTitle } from "../../ui/dialog" -import * as Popover from "../../ui/popover" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ details: Popover.Model, pinned: Popover.Model, interaction: Interaction.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotDetailsMessage: { message: Popover.Message }, - GotPinnedMessage: { message: Popover.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const foldDetails = Update.foldChild({ - update: Popover.update, - read: (model: Model) => Option.some(model.details), - write: (model, nextDetails) => modifyFields(model, { details: () => nextDetails }), - toParentMessage: (message) => Message.GotDetailsMessage({ message }), -}) - -const foldPinned = Update.foldChild({ - update: Popover.update, - read: (model: Model) => Option.some(model.pinned), - write: (model, nextPinned) => modifyFields(model, { pinned: () => nextPinned }), - toParentMessage: (message) => Message.GotPinnedMessage({ message }), -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Popover", [ - gallerySection(h, "Popover", [ - h.submodel({ - slotId: "details", - model: model.details, - view: Popover.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button( - h, - { - intent: "outline", - attributes, - interaction: { wiring: interaction.wiring(model), target: "details" }, - }, - ["Details", overlay], - ), - toContent: (closeAttributes) => [ - dialogHeader(h, {}, [ - dialogTitle(h, {}, "Notifications"), - dialogDescription(h, "Choose when this channel notifies you."), - ]), - dialogBody(h, [ - h.p([h.Class("text-sm/6")], ["Mentions and replies always notify you."]), - ]), - Popover.popoverFooter(h, [dialogClose(h, closeAttributes, ["Close"])]), - ], - }, - toParentMessage: (message) => Message.GotDetailsMessage({ message }), - }), - h.submodel({ - slotId: "pinned", - model: model.pinned, - view: Popover.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button( - h, - { - intent: "outline", - attributes, - interaction: { wiring: interaction.wiring(model), target: "with-arrow" }, - }, - ["With arrow", overlay], - ), - toContent: () => [ - dialogHeader(h, {}, [ - dialogTitle(h, {}, "Pinned"), - dialogDescription(h, "Two messages are pinned here."), - ]), - ], - arrow: true, - placement: "right", - }, - toParentMessage: (message) => Message.GotPinnedMessage({ message }), - }), - ]), - ]) - -export const gallery = defineGallery("Popover", { - Model, - init: () => ({ - model: { - interaction: Interaction.init(), - details: Popover.init("details"), - pinned: Popover.init("pinned"), - }, - }), - update: (model, message) => - Message.match>(message, { - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - GotDetailsMessage: ({ message }) => foldDetails(model, message), - GotPinnedMessage: ({ message }) => foldPinned(model, message), - }), - subscriptions: interaction.subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/progress-bar.ts b/apps/web-foldkit/src/gallery/entries/progress-bar.ts deleted file mode 100644 index 7e6f91af1..000000000 --- a/apps/web-foldkit/src/gallery/entries/progress-bar.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { progressBar, progressBarHeader, progressBarTrack, progressBarValue } from "../../ui/progress-bar" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => { - const sized = (child: ReturnType) => h.div([h.Class("w-72")], [child]) - return galleryFrame(h, "Progress bar", [ - gallerySection(h, "Determinate", [ - sized( - progressBar(h, { ariaLabel: "Uploading", value: 40 }, [ - progressBarHeader(h, {}, [h.span([], ["Uploading"]), progressBarValue(h, { value: 40 })]), - progressBarTrack(h, { value: 40 }), - ]), - ), - sized(progressBar(h, { ariaLabel: "Empty", value: 0 }, [progressBarTrack(h, { value: 0 })])), - sized( - progressBar(h, { ariaLabel: "Complete", value: 100 }, [ - progressBarHeader(h, {}, [h.span([], ["Complete"]), progressBarValue(h, { value: 100 })]), - progressBarTrack(h, { value: 100, className: "bg-muted" }), - ]), - ), - ]), - gallerySection(h, "Indeterminate", [ - sized( - progressBar(h, { ariaLabel: "Loading", isIndeterminate: true }, [ - progressBarTrack(h, { isIndeterminate: true }), - ]), - ), - ]), - ]) -} - -export const gallery = defineGallery("Progress bar", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/radio.ts b/apps/web-foldkit/src/gallery/entries/radio.ts deleted file mode 100644 index fcb346cac..000000000 --- a/apps/web-foldkit/src/gallery/entries/radio.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { radioGroup, type RadioGroupOptions, type RadioGroupParts } from "../../ui/radio" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - values: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - SelectedRadio: { group: Schema.String, value: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - SelectedRadio: ({ group, value }) => ({ - model: modifyFields(model, { values: (values) => ({ ...values, [group]: value }) }), - }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialValues: Record = { - density: "comfortable", - theme: null, - disabled: "on", - invalid: null, -} - -const view = (model: Model, h: HtmlBuilder) => { - const group = ( - id: string, - options: Omit, "id" | "value">, - render: (parts: RadioGroupParts) => Array, - ) => - h.div( - [h.Class("w-72")], - [ - radioGroup( - h, - { - id, - value: model.values[id] ?? null, - onChange: (value) => Message.SelectedRadio({ group: id, value }), - interaction: interaction.wiring(model), - ...options, - }, - render, - ), - ], - ) - return galleryFrame(h, "Radio", [ - gallerySection(h, "Groups", [ - group("density", {}, (g) => [ - g.label(["Density"]), - g.radio("compact", "Compact"), - g.radio("comfortable", "Comfortable"), - g.radio("spacious", "Spacious"), - ]), - group("theme", {}, (g) => [ - g.label(["Theme"]), - g.description(["Applies to every device."]), - g.radio("light", "Light"), - g.radio("dark", "Dark"), - ]), - ]), - gallerySection(h, "States", [ - group("disabled", { isDisabled: true }, (g) => [ - g.label(["Disabled"]), - g.radio("on", "Disabled selected"), - g.radio("off", "Disabled"), - ]), - group("invalid", { isInvalid: true }, (g) => [ - g.label(["Invalid"]), - g.radio("yes", "Yes"), - g.radio("no", "No"), - g.fieldError(["Pick one."]), - ]), - ]), - ]) -} - -export const gallery = defineGallery("Radio", { - Model, - init: () => ({ model: { values: initialValues, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/range-calendar.ts b/apps/web-foldkit/src/gallery/entries/range-calendar.ts deleted file mode 100644 index 0003af77b..000000000 --- a/apps/web-foldkit/src/gallery/entries/range-calendar.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Calendar from "../../ui/calendar" -import * as D from "../../ui/calendar-date" -import { view as calendarView } from "../../ui/calendar-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ trip: Calendar.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotTripMessage: { message: Calendar.Message }, -}) -type Message = typeof Message.Type - -const toTripMessage = (message: Calendar.Message) => Message.GotTripMessage({ message }) - -// UPDATE - -const foldTrip = Update.foldChild({ - update: Calendar.update, - read: (model: Model) => Option.some(model.trip), - write: (_model: Model, trip: Calendar.Model): Model => ({ trip }), - toParentMessage: toTripMessage, - foldOutMessage: () => (model: Model) => ({ model }), -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "RangeCalendar", [ - gallerySection(h, "Selected range", [ - h.submodel({ - slotId: "trip", - model: model.trip, - view: calendarView, - viewInputs: { ariaLabel: "Trip dates" }, - toParentMessage: toTripMessage, - }), - ]), - ]) - -export const gallery = defineGallery("RangeCalendar", { - Model, - init: () => ({ - model: { - trip: Calendar.init({ - id: "trip", - mode: "Range", - today: D.today(), - range: { start: "2026-03-09", end: "2026-03-13" }, - }), - }, - }), - update: (model, message) => - Message.match>(message, { - GotTripMessage: ({ message }) => foldTrip(model, message), - }), - subscriptions: Subscription.lift(Calendar.subscriptions)({ - read: (model) => Option.some(model.trip), - toParentMessage: toTripMessage, - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/search-field.ts b/apps/web-foldkit/src/gallery/entries/search-field.ts deleted file mode 100644 index 965cfed92..000000000 --- a/apps/web-foldkit/src/gallery/entries/search-field.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Effect, Schema } from "effect" -import { Command, type Update } from "foldkit" -import * as Dom from "foldkit/dom" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { - searchField, - searchFieldIds, - type SearchFieldOptions, - type SearchFieldParts, -} from "../../ui/search-field" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - values: Schema.Record(Schema.String, Schema.String), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - UpdatedSearch: { field: Schema.String, value: Schema.String }, - ClearedSearch: { field: Schema.String }, - PressedClearButton: { field: Schema.String }, - CompletedFocusSearchInput: {}, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// COMMAND - -const FocusSearchInput = Command.define("FocusSearchInput", { - args: { field: Schema.String }, - messages: [Message.CompletedFocusSearchInput], - execute: ({ field }) => - Dom.focus(`#${searchFieldIds(field).input}`).pipe( - Effect.ignore, - Effect.as(Message.CompletedFocusSearchInput()), - ), -}) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - UpdatedSearch: ({ field, value }) => ({ - model: modifyFields(model, { values: (values) => ({ ...values, [field]: value }) }), - }), - ClearedSearch: ({ field }) => ({ - model: modifyFields(model, { values: (values) => ({ ...values, [field]: "" }) }), - }), - PressedClearButton: ({ field }) => ({ model, commands: [FocusSearchInput({ field })] }), - CompletedFocusSearchInput: () => ({ model }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialValues: Record = { - messages: "", - channels: "general", - disabled: "Disabled", - members: "", -} - -const view = (model: Model, h: HtmlBuilder) => { - const field = ( - id: string, - options: Omit, "id" | "value">, - render: (parts: SearchFieldParts) => Array, - ) => - h.div( - [h.Class("w-72")], - [ - searchField( - h, - { - id, - value: model.values[id] ?? "", - onInput: (value) => Message.UpdatedSearch({ field: id, value }), - onClear: Message.ClearedSearch({ field: id }), - onClearPressStart: Message.PressedClearButton({ field: id }), - interaction: interaction.wiring(model), - ...options, - }, - render, - ), - ], - ) - return galleryFrame(h, "Search field", [ - gallerySection(h, "Search fields", [ - field("messages", { ariaLabel: "Search messages" }, (f) => [ - f.searchInput({ placeholder: "Search messages" }), - ]), - field("channels", { ariaLabel: "Search channels" }, (f) => [ - f.searchInput({ placeholder: "Search channels" }), - ]), - field("disabled", { ariaLabel: "Search disabled", isDisabled: true }, (f) => [ - f.searchInput({ placeholder: "Disabled" }), - ]), - ]), - gallerySection(h, "With label", [ - field("members", {}, (f) => [ - f.label(["Members"]), - f.searchInput({ placeholder: "Find a member" }), - f.description(["Search by name or email."]), - ]), - ]), - ]) -} - -export const gallery = defineGallery("Search field", { - Model, - init: () => ({ model: { values: initialValues, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/section-header.ts b/apps/web-foldkit/src/gallery/entries/section-header.ts deleted file mode 100644 index 35f520e6f..000000000 --- a/apps/web-foldkit/src/gallery/entries/section-header.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { button } from "../../ui/button" -import { sectionFooterActions, sectionFooterRoot } from "../../ui/section-footer" -import { - sectionHeaderActions, - sectionHeaderGroup, - sectionHeaderHeading, - sectionHeaderRoot, - sectionHeaderSubheading, -} from "../../ui/section-header" -import { sectionLabelActions, sectionLabelRoot } from "../../ui/section-label" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Section header", [ - gallerySection(h, "Header", [ - h.div( - [h.Class("w-[36rem]")], - [ - sectionHeaderRoot(h, {}, [ - sectionHeaderGroup(h, {}, [ - h.div( - [h.Class("flex flex-1 flex-col gap-1")], - [ - sectionHeaderHeading(h, {}, ["Members"]), - sectionHeaderSubheading(h, {}, [ - "Manage who has access to this workspace.", - ]), - ], - ), - sectionHeaderActions(h, {}, [ - button(h, { intent: "outline", size: "sm" }, ["Export"]), - button(h, { size: "sm" }, ["Invite"]), - ]), - ]), - ]), - ], - ), - sectionHeaderHeading(h, { size: "xl" }, ["Extra large heading"]), - ]), - gallerySection(h, "Label", [ - sectionLabelRoot(h, { title: "Display name", description: "Shown to everyone." }), - sectionLabelRoot( - h, - { title: "Email", size: "md", isRequired: true, description: "Used for sign in." }, - [sectionLabelActions(h, {}, [button(h, { size: "xs" }, ["Verify"])])], - ), - ]), - gallerySection(h, "Footer", [ - h.div( - [h.Class("flex w-[36rem] flex-col gap-6")], - [ - sectionFooterRoot(h, {}, [ - h.span([h.Class("text-muted-fg text-sm")], ["Changes are saved per workspace."]), - sectionFooterActions(h, {}, [ - button(h, { intent: "outline", size: "sm" }, ["Cancel"]), - button(h, { size: "sm" }, ["Save"]), - ]), - ]), - sectionFooterRoot(h, { isCard: true }, [ - sectionFooterActions(h, {}, [button(h, { size: "sm" }, ["Save card"])]), - ]), - ], - ), - ]), - ]) - -export const gallery = defineGallery("Section header", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/select.ts b/apps/web-foldkit/src/gallery/entries/select.ts deleted file mode 100644 index e1514bca6..000000000 --- a/apps/web-foldkit/src/gallery/entries/select.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Array, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Select from "../../ui/select" -import { view as selectView } from "../../ui/select-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ selects: Schema.Array(Select.Model) }) -type Model = typeof Model.Type - -const options = [ - Select.item("never", "Don't clear"), - Select.item("30m", "30 minutes"), - Select.item("1h", "1 hour"), - Select.item("today", "Today"), - Select.item("week", "This week"), -] - -const examples = [ - { id: "clear-after", label: "Clear after", selectedKey: "1h" }, - { id: "reminder", label: "Reminder", placeholder: "Choose a time" }, - { id: "disabled", label: "Disabled", selectedKey: "today", isDisabled: true }, -] as const - -// MESSAGE - -const Message = defineMessageUnion({ - GotSelectMessage: { id: Schema.String, message: Select.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldSelectOutMessage = Select.OutMessage.match>({ - ChangedSelection: () => (model) => ({ model }), -}) - -const foldSelect = (id: string) => - Update.foldChild({ - update: Select.update, - read: (model: Model) => Array.findFirst(model.selects, (select) => select.id === id), - write: (model, nextSelect) => - modifyFields(model, { selects: Array.map((select) => (select.id === id ? nextSelect : select)) }), - toParentMessage: (message) => Message.GotSelectMessage({ id, message }), - foldOutMessage: foldSelectOutMessage, - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Select", [ - gallerySection( - h, - "States", - Array.map(model.selects, (select) => { - const example = Array.findFirst(examples, (candidate) => candidate.id === select.id) - return h.submodel({ - slotId: select.id, - model: select, - view: selectView, - viewInputs: { - label: example._tag === "Some" ? example.value.label : "", - placeholder: - example._tag === "Some" && "placeholder" in example.value - ? example.value.placeholder - : undefined, - className: "w-64", - }, - toParentMessage: (message) => Message.GotSelectMessage({ id: select.id, message }), - }) - }), - ), - ]) - -export const gallery = defineGallery("Select", { - Model, - init: () => ({ - model: { - selects: Array.map(examples, (example) => - Select.init({ - id: example.id, - items: options, - selectedKey: "selectedKey" in example ? example.selectedKey : undefined, - isDisabled: "isDisabled" in example ? example.isDisabled : false, - }), - ), - }, - }), - update: (model, message) => - Message.match>(message, { - GotSelectMessage: ({ id, message }) => foldSelect(id)(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/separator.ts b/apps/web-foldkit/src/gallery/entries/separator.ts deleted file mode 100644 index 172b2bcb3..000000000 --- a/apps/web-foldkit/src/gallery/entries/separator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { separator } from "../../ui/separator" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Separator", [ - gallerySection(h, "Horizontal", [ - h.div( - [h.Class("w-64")], - [ - h.p([h.Class("text-sm")], ["Above"]), - separator(h, { className: "my-2" }), - h.p([h.Class("text-sm")], ["Below"]), - ], - ), - ]), - gallerySection(h, "Vertical", [ - h.div( - [h.Class("flex h-8 items-center gap-2 text-sm")], - [h.span([], ["Left"]), separator(h, { orientation: "vertical" }), h.span([], ["Right"])], - ), - ]), - ]) - -export const gallery = defineGallery("Separator", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/sheet.ts b/apps/web-foldkit/src/gallery/entries/sheet.ts deleted file mode 100644 index 99d08efe2..000000000 --- a/apps/web-foldkit/src/gallery/entries/sheet.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { button } from "../../ui/button" -import { - dialogBody, - dialogClose, - dialogDescription, - dialogFooter, - dialogHeader, - dialogTitle, -} from "../../ui/dialog" -import * as Modal from "../../ui/modal" -import * as Sheet from "../../ui/sheet" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ details: Modal.Model, navigation: Modal.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotDetailsMessage: { message: Modal.Message }, - GotNavigationMessage: { message: Modal.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldDetails = Update.foldChild({ - update: Modal.update, - read: (model: Model) => Option.some(model.details), - write: (model, nextDetails) => modifyFields(model, { details: () => nextDetails }), - toParentMessage: (message) => Message.GotDetailsMessage({ message }), -}) - -const foldNavigation = Update.foldChild({ - update: Modal.update, - read: (model: Model) => Option.some(model.navigation), - write: (model, nextNavigation) => modifyFields(model, { navigation: () => nextNavigation }), - toParentMessage: (message) => Message.GotNavigationMessage({ message }), -}) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Sheet", [ - gallerySection(h, "Sheet", [ - h.submodel({ - slotId: "details", - model: model.details, - view: Sheet.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "outline", attributes }, ["Open sheet", overlay]), - toContent: (closeAttributes) => [ - dialogHeader(h, {}, [ - dialogTitle(h, { id: Modal.titleId("details") }, "Channel details"), - dialogDescription(h, "Members, files and settings for this channel."), - ]), - dialogBody(h, [ - h.p([h.Class("text-sm/6")], ["Twelve members have access to this channel."]), - ]), - dialogFooter(h, [dialogClose(h, closeAttributes, ["Close"])]), - ], - }, - toParentMessage: (message) => Message.GotDetailsMessage({ message }), - }), - h.submodel({ - slotId: "navigation", - model: model.navigation, - view: Sheet.view, - viewInputs: { - toTrigger: (attributes, overlay) => - button(h, { intent: "outline", attributes }, ["Open left sheet", overlay]), - toContent: () => [ - dialogHeader(h, { - title: { id: Modal.titleId("navigation"), text: "Navigation" }, - description: "Jump to a channel.", - }), - ], - side: "left", - isFloat: false, - }, - toParentMessage: (message) => Message.GotNavigationMessage({ message }), - }), - ]), - ]) - -export const gallery = defineGallery("Sheet", { - Model, - init: () => ({ model: { details: Modal.init("details"), navigation: Modal.init("navigation") } }), - update: (model, message) => - Message.match>(message, { - GotDetailsMessage: ({ message }) => foldDetails(model, message), - GotNavigationMessage: ({ message }) => foldNavigation(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/slider.ts b/apps/web-foldkit/src/gallery/entries/slider.ts deleted file mode 100644 index 2c80296a7..000000000 --- a/apps/web-foldkit/src/gallery/entries/slider.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Interaction from "../../ui/aria/interaction" -import * as Slider from "../../ui/slider" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const SliderKey = Schema.Literals(["volume", "price", "disabled", "vertical"]) -type SliderKey = typeof SliderKey.Type - -const Model = Schema.Struct({ - volume: Slider.Model, - price: Slider.Model, - disabled: Slider.Model, - vertical: Slider.Model, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotSliderMessage: { sliderId: SliderKey, message: Slider.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -const toSliderMessage = - (slider: SliderKey) => - (message: Slider.Message): Message => - Message.GotSliderMessage({ sliderId: slider, message }) - -const foldSlider = (slider: SliderKey) => - Update.foldChild({ - update: Slider.update, - read: (model: Model) => Option.some(model[slider]), - write: (model: Model, next: Slider.Model): Model => ({ ...model, [slider]: next }), - toParentMessage: toSliderMessage(slider), - }) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotSliderMessage: ({ sliderId: slider, message }) => foldSlider(slider)(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -const liftSlider = (slider: SliderKey) => - Subscription.lift(Slider.subscriptions)({ - read: (model) => Option.some(model[slider]), - toParentMessage: toSliderMessage(slider), - }) - -const subscriptions = Subscription.aggregate()( - interaction.subscriptions, - { volumeDrag: liftSlider("volume").drag }, - { priceDrag: liftSlider("price").drag }, - { verticalDrag: liftSlider("vertical").drag }, -) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const labelled = (slider: SliderKey, label: string) => - Slider.slider( - h, - { - model: model[slider], - toParentMessage: toSliderMessage(slider), - interaction: interaction.wiring(model), - }, - (parts): Array => [ - h.div([h.Class("flex items-center justify-between")], [parts.label([label]), parts.output()]), - parts.track(), - ], - ) - return galleryFrame(h, "Slider", [ - gallerySection(h, "Sliders", [ - h.div([h.Class("w-72")], [labelled("volume", "Volume")]), - h.div([h.Class("w-72")], [labelled("price", "Price range")]), - ]), - gallerySection(h, "States", [ - h.div([h.Class("w-72")], [labelled("disabled", "Disabled")]), - h.div( - [h.Class("h-40")], - [ - Slider.slider( - h, - { - model: model.vertical, - toParentMessage: toSliderMessage("vertical"), - ariaLabel: "Vertical", - interaction: interaction.wiring(model), - }, - (parts) => [parts.track()], - ), - ], - ), - ]), - ]) -} - -export const gallery = defineGallery("Slider", { - Model, - init: () => ({ - model: { - volume: Slider.init({ id: "volume", values: [40] }), - price: Slider.init({ id: "price", values: [20, 70] }), - disabled: Slider.init({ id: "disabled", values: [60], isDisabled: true }), - vertical: Slider.init({ id: "vertical", values: [30], orientation: "vertical" }), - interaction: Interaction.init(), - }, - }), - update, - view, - subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/switch.ts b/apps/web-foldkit/src/gallery/entries/switch.ts deleted file mode 100644 index e9bb826c0..000000000 --- a/apps/web-foldkit/src/gallery/entries/switch.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { description, label } from "../../ui/field" -import { switchControl, type SwitchOptions } from "../../ui/switch" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - selected: Schema.Record(Schema.String, Schema.Boolean), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ToggledSwitch: { id: Schema.String, isSelected: Schema.Boolean }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - ToggledSwitch: ({ id, isSelected }) => ({ - model: modifyFields(model, { selected: (selected) => ({ ...selected, [id]: isSelected }) }), - }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialSelected: Record = { - off: false, - on: true, - disabled: false, - "disabled-on": true, - desktop: false, -} - -const view = (model: Model, h: HtmlBuilder) => { - const item = (id: string, options: Omit, "id" | "isSelected"> = {}) => ({ - id, - isSelected: model.selected[id] ?? false, - onChange: (isSelected: boolean) => Message.ToggledSwitch({ id, isSelected }), - interaction: interaction.wiring(model), - ...options, - }) - const box = (width: string, child: ReturnType) => h.div([h.Class(width)], [child]) - return galleryFrame(h, "Switch", [ - gallerySection(h, "States", [ - box("w-64", switchControl(h, item("off"), "Off")), - box("w-64", switchControl(h, item("on"), "On")), - box("w-64", switchControl(h, item("disabled", { isDisabled: true }), "Disabled")), - box("w-64", switchControl(h, item("disabled-on", { isDisabled: true }), "Disabled on")), - ]), - gallerySection(h, "With description", [ - box( - "w-80", - switchControl(h, item("desktop"), [ - label(h, { elementType: "span" }, ["Desktop notifications"]), - description(h, {}, ["Show a banner for new messages."]), - ]), - ), - ]), - ]) -} - -export const gallery = defineGallery("Switch", { - Model, - init: () => ({ model: { selected: initialSelected, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/table-selection.ts b/apps/web-foldkit/src/gallery/entries/table-selection.ts deleted file mode 100644 index 935af5336..000000000 --- a/apps/web-foldkit/src/gallery/entries/table-selection.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Option, Schema } from "effect" -import { Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Table from "../../ui/table" -import * as TableView from "../../ui/table-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -/** Port of `dev-gallery/entries/table-selection.tsx`: multiple selection with checkboxes. */ - -// MODEL - -const Model = Schema.Struct({ invite: Table.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotInviteMessage: { message: Table.Message }, -}) -type Message = typeof Message.Type - -const toInviteMessage = (message: Table.Message) => Message.GotInviteMessage({ message }) - -// UPDATE - -const foldInvite = Update.foldChild({ - update: Table.update, - read: (model: Model) => Option.some(model.invite), - write: (_model: Model, invite: Table.Model): Model => ({ invite }), - toParentMessage: toInviteMessage, -}) - -// VIEW - -const members = [ - { id: "ada", name: "Ada Lovelace", role: "Owner" }, - { id: "grace", name: "Grace Hopper", role: "Admin" }, - { id: "alan", name: "Alan Turing", role: "Member" }, - { id: "katherine", name: "Katherine Johnson", role: "Guest" }, -] - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Table selection", [ - gallerySection(h, "Checkbox selection", [ - h.submodel({ - slotId: "invite", - model: model.invite, - view: TableView.view, - viewInputs: { - label: "Invite members", - className: "w-[32rem]", - columns: [ - { key: "name", content: "Name", isRowHeader: true }, - { key: "role", content: "Role" }, - ], - rows: members.map((member) => ({ - key: member.id, - cells: [member.name, member.role], - isDisabled: member.id === "katherine", - })), - }, - toParentMessage: toInviteMessage, - }), - ]), - ]) - -export const gallery = defineGallery("Table selection", { - Model, - init: () => ({ - model: { - invite: Table.init({ id: "table-invite", selectionMode: "multiple", selectedKeys: ["grace"] }), - }, - }), - update: (model, message) => - Message.match>(message, { - GotInviteMessage: ({ message }) => foldInvite(model, message), - }), - subscriptions: Subscription.lift(Table.subscriptions)({ - read: (model) => Option.some(model.invite), - toParentMessage: toInviteMessage, - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/table.ts b/apps/web-foldkit/src/gallery/entries/table.ts deleted file mode 100644 index fa66c10d8..000000000 --- a/apps/web-foldkit/src/gallery/entries/table.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Array, Option, Order, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Table from "../../ui/table" -import * as TableView from "../../ui/table-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Slot = Schema.Literals(["members", "sortable", "grid", "striped", "empty"]) -type Slot = typeof Slot.Type - -const Model = Schema.Struct({ - members: Table.Model, - sortable: Table.Model, - grid: Table.Model, - striped: Table.Model, - empty: Table.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotTableMessage: { slotId: Slot, message: Table.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldTable = (slot: Slot) => - Update.foldChild({ - update: Table.update, - read: (model: Model) => Option.some(model[slot]), - write: (model, nextTable) => ({ ...model, [slot]: nextTable }), - toParentMessage: (message) => Message.GotTableMessage({ slotId: slot, message }), - }) - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotTableMessage: ({ slotId: slot, message }) => foldTable(slot)(model, message), - }) - -// VIEW - -interface Member { - readonly id: string - readonly name: string - readonly role: string - readonly channels: number -} - -const members: ReadonlyArray = [ - { id: "ada", name: "Ada Lovelace", role: "Owner", channels: 12 }, - { id: "grace", name: "Grace Hopper", role: "Admin", channels: 7 }, - { id: "alan", name: "Alan Turing", role: "Member", channels: 3 }, - { - id: "katherine", - name: "Katherine Johnson with a much longer display name", - role: "Guest", - channels: 1, - }, -] - -const columns = (allowsSorting: boolean): ReadonlyArray => [ - { key: "name", content: "Name", isRowHeader: true, allowsSorting }, - { key: "role", content: "Role" }, - { key: "channels", content: "Channels", allowsSorting }, -] - -const toRows = (rows: ReadonlyArray, disabledKeys: ReadonlyArray = []) => - rows.map((member) => ({ - key: member.id, - cells: [member.name, member.role, String(member.channels)], - isDisabled: disabledKeys.includes(member.id), - })) - -const sorted = (sort: Option.Option) => - Option.match(sort, { - onNone: () => members, - onSome: ({ column, direction }) => { - const byColumn: Order.Order = - column === "channels" - ? Order.mapInput(Order.Number, (member: Member) => member.channels) - : Order.mapInput(Order.String, (member: Member) => member.name) - return Array.sort(members, direction === "ascending" ? byColumn : Order.flip(byColumn)) - }, - }) - -const view = (model: Model, h: HtmlBuilder) => { - const table = (slot: Slot, viewInputs: TableView.ViewInputs) => - h.submodel({ - slotId: slot, - model: model[slot], - view: TableView.view, - viewInputs, - toParentMessage: (message) => Message.GotTableMessage({ slotId: slot, message }), - }) - return galleryFrame(h, "Table", [ - gallerySection(h, "Selectable", [ - table("members", { - label: "Members", - className: "w-[40rem]", - columns: columns(false), - rows: toRows(members, ["alan"]), - }), - ]), - gallerySection(h, "Sortable", [ - table("sortable", { - label: "Sortable members", - columns: columns(true), - rows: toRows(sorted(Table.sortOf(model.sortable))), - }), - ]), - gallerySection(h, "Grid and striped", [ - table("grid", { - label: "Grid members", - grid: true, - columns: columns(false), - rows: toRows(members.slice(0, 2)), - }), - table("striped", { - label: "Striped members", - striped: true, - columns: columns(false), - rows: toRows(members.slice(0, 3)), - }), - ]), - gallerySection(h, "Empty", [ - table("empty", { - label: "No members", - className: "w-[30rem]", - columns: columns(false), - rows: [], - emptyState: [h.p([h.Class("py-6 text-center")], ["No members yet"])], - }), - ]), - ]) -} - -export const gallery = defineGallery("Table", { - Model, - init: () => ({ - model: { - members: Table.init({ id: "table-members", selectionMode: "single", selectedKeys: ["grace"] }), - sortable: Table.init({ id: "table-sortable", sort: { column: "name", direction: "ascending" } }), - grid: Table.init({ id: "table-grid" }), - striped: Table.init({ id: "table-striped" }), - empty: Table.init({ id: "table-empty" }), - }, - }), - update, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/tabs.ts b/apps/web-foldkit/src/gallery/entries/tabs.ts deleted file mode 100644 index fab426140..000000000 --- a/apps/web-foldkit/src/gallery/entries/tabs.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconGear, IconUsers } from "../../icons" -import * as Tabs from "../../ui/tabs" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ - horizontal: Tabs.Model, - vertical: Tabs.Model, - icons: Tabs.Model, - long: Tabs.Model, -}) -type Model = typeof Model.Type -type Slot = keyof Model - -// MESSAGE - -const Message = defineMessageUnion({ - GotTabsMessage: { - slotId: Schema.Literals(["horizontal", "vertical", "icons", "long"]), - message: Tabs.Message, - }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldTabs = (slot: Slot) => - Update.foldChild({ - update: Tabs.update, - read: (model: Model) => Option.some(model[slot]), - write: (model, nextTabs) => ({ ...model, [slot]: nextTabs }), - toParentMessage: (message) => Message.GotTabsMessage({ slotId: slot, message }), - }) - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotTabsMessage: ({ slotId: slot, message }) => foldTabs(slot)(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const tabs = (slot: Slot, viewInputs: Tabs.ViewInputs) => - h.submodel({ - slotId: slot, - model: model[slot], - view: Tabs.view, - viewInputs, - toParentMessage: (message) => Message.GotTabsMessage({ slotId: slot, message }), - }) - const simple = (labels: ReadonlyArray, disabledKey?: string) => ({ - tabs: labels.map(([key, label]) => ({ key, content: [label], isDisabled: key === disabledKey })), - panels: labels.map(([key, label]) => ({ key, content: [`${label} panel`] })), - }) - const iconAttributes = { attributes: { "data-slot": "icon" } } - - return galleryFrame(h, "Tabs", [ - gallerySection(h, "Horizontal", [ - tabs("horizontal", { - listLabel: "Workspace sections", - ...simple( - [ - ["overview", "Overview"], - ["members", "Members"], - ["billing", "Billing"], - ["integrations", "Integrations"], - ], - "billing", - ), - }), - ]), - gallerySection(h, "Vertical", [ - tabs("vertical", { - listLabel: "Account sections", - ...simple( - [ - ["profile", "Profile"], - ["security", "Security"], - ["sessions", "Sessions"], - ["notifications", "Notifications"], - ], - "sessions", - ), - }), - ]), - gallerySection(h, "Icons, preselected", [ - tabs("icons", { - listLabel: "Team sections", - tabs: [ - { key: "people", content: [IconUsers(h, iconAttributes), "People"] }, - { key: "settings", content: [IconGear(h, iconAttributes), "Preferences"] }, - ], - panels: [ - { key: "people", content: ["People panel"] }, - { key: "settings", content: ["Preferences panel"] }, - ], - }), - ]), - gallerySection(h, "Long labels", [ - tabs("long", { - listLabel: "Long sections", - tabs: [ - { key: "first", content: ["Notification delivery preferences and schedules"] }, - { key: "second", content: ["Connected third-party integrations"] }, - ], - panels: [ - { key: "first", content: ["First long panel"] }, - { key: "second", content: ["Second long panel"] }, - ], - }), - ]), - ]) -} - -export const gallery = defineGallery("Tabs", { - Model, - init: () => ({ - model: { - horizontal: Tabs.init({ id: "tabs-horizontal", selectedKey: "overview" }), - vertical: Tabs.init({ id: "tabs-vertical", selectedKey: "profile", orientation: "vertical" }), - icons: Tabs.init({ id: "tabs-icons", selectedKey: "settings" }), - long: Tabs.init({ id: "tabs-long", selectedKey: "first" }), - }, - }), - update, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/text-field.ts b/apps/web-foldkit/src/gallery/entries/text-field.ts deleted file mode 100644 index eacd4179d..000000000 --- a/apps/web-foldkit/src/gallery/entries/text-field.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { textField, type TextFieldOptions, type TextFieldParts } from "../../ui/text-field" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - values: Schema.Record(Schema.String, Schema.String), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - UpdatedFieldValue: { field: Schema.String, value: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - UpdatedFieldValue: ({ field, value }) => ({ - model: modifyFields(model, { values: (values) => ({ ...values, [field]: value }) }), - }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialValues: Record = { - name: "", - email: "ada@hazel.sh", - username: "", - invalid: "not-an-email", - disabled: "Read only value", - required: "", -} - -const view = (model: Model, h: HtmlBuilder) => { - const field = ( - id: string, - options: Omit, "id" | "value">, - render: (parts: TextFieldParts) => Array, - ) => - h.div( - [h.Class("w-72")], - [ - textField( - h, - { - id, - value: model.values[id] ?? "", - onInput: (value) => Message.UpdatedFieldValue({ field: id, value }), - interaction: interaction.wiring(model), - ...options, - }, - render, - ), - ], - ) - return galleryFrame(h, "Text field", [ - gallerySection(h, "Fields", [ - field("name", {}, (f) => [f.label(["Name"]), f.input({ placeholder: "Ada Lovelace" })]), - field("email", {}, (f) => [ - f.label(["Email"]), - f.input(), - f.description(["We never share your email."]), - ]), - field("username", {}, (f) => [ - f.label(["Username"]), - f.description(["Lowercase letters and numbers."]), - f.input({ placeholder: "ada" }), - ]), - ]), - gallerySection(h, "States", [ - field("invalid", { isInvalid: true }, (f) => [ - f.label(["Invalid"]), - f.input(), - f.fieldError(["Enter a valid email address."]), - ]), - field("disabled", { isDisabled: true }, (f) => [ - f.label(["Disabled"]), - f.input(), - f.description(["This field is disabled."]), - ]), - field("required", { isRequired: true }, (f) => [ - f.label(["Required"]), - f.input({ placeholder: "Required" }), - ]), - ]), - ]) -} - -export const gallery = defineGallery("Text field", { - Model, - init: () => ({ model: { values: initialValues, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/text.ts b/apps/web-foldkit/src/gallery/entries/text.ts deleted file mode 100644 index 2a2e303dc..000000000 --- a/apps/web-foldkit/src/gallery/entries/text.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Schema } from "effect" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { code, keyboard, strong, text } from "../../ui/text" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -const Model = Schema.Struct({}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ IgnoredGallery: {} }) -type Message = typeof Message.Type - -const view = (_model: Model, h: HtmlBuilder) => - galleryFrame(h, "Text", [ - gallerySection(h, "Text", [ - text(h, { className: "max-w-md" }, [ - "Messages are kept for ", - strong(h, {}, ["90 days"]), - " on the free plan. Run ", - code(h, {}, ["hazel export"]), - " ", - "to download a copy before they expire.", - ]), - ]), - gallerySection(h, "Keyboard", [ - keyboard(h, {}, ["⌘K"]), - keyboard(h, { className: "text-fg" }, ["Ctrl Shift P"]), - ]), - ]) - -export const gallery = defineGallery("Text", { - Model, - init: () => ({ model: {} }), - update: (model) => ({ model }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/textarea.ts b/apps/web-foldkit/src/gallery/entries/textarea.ts deleted file mode 100644 index dfa4d7a0b..000000000 --- a/apps/web-foldkit/src/gallery/entries/textarea.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Interaction from "../../ui/aria/interaction" -import { textField, type TextFieldOptions, type TextFieldParts } from "../../ui/text-field" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - values: Schema.Record(Schema.String, Schema.String), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - UpdatedFieldValue: { field: Schema.String, value: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - UpdatedFieldValue: ({ field, value }) => ({ - model: modifyFields(model, { values: (values) => ({ ...values, [field]: value }) }), - }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const initialValues: Record = { - message: "", - notes: "Line one\nLine two\nLine three", - invalid: "Too short", - disabled: "Disabled text", -} - -const view = (model: Model, h: HtmlBuilder) => { - const field = ( - id: string, - options: Omit, "id" | "value">, - render: (parts: TextFieldParts) => Array, - ) => - h.div( - [h.Class("w-80")], - [ - textField( - h, - { - id, - value: model.values[id] ?? "", - onInput: (value) => Message.UpdatedFieldValue({ field: id, value }), - interaction: interaction.wiring(model), - ...options, - }, - render, - ), - ], - ) - return galleryFrame(h, "Textarea", [ - gallerySection(h, "Textareas", [ - field("message", {}, (f) => [ - f.label(["Message"]), - f.textarea({ placeholder: "Write something" }), - ]), - field("notes", {}, (f) => [ - f.label(["Notes"]), - f.textarea(), - f.description(["Grows with its content."]), - ]), - ]), - gallerySection(h, "States", [ - field("invalid", { isInvalid: true }, (f) => [ - f.label(["Invalid"]), - f.textarea(), - f.fieldError(["Write at least 20 characters."]), - ]), - field("disabled", { isDisabled: true }, (f) => [f.label(["Disabled"]), f.textarea()]), - ]), - ]) -} - -export const gallery = defineGallery("Textarea", { - Model, - init: () => ({ model: { values: initialValues, interaction: Interaction.init() } }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/time-field.ts b/apps/web-foldkit/src/gallery/entries/time-field.ts deleted file mode 100644 index 1e8cc5e40..000000000 --- a/apps/web-foldkit/src/gallery/entries/time-field.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import * as Interaction from "../../ui/aria/interaction" -import { dateField } from "../../ui/date-field" -import * as Segments from "../../ui/date-segments" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const FieldKey = Schema.Literals(["start", "reminder", "disabled"]) -type FieldKey = typeof FieldKey.Type - -const Model = Schema.Struct({ - start: Segments.Model, - reminder: Segments.Model, - disabled: Segments.Model, - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotTimeFieldMessage: { fieldId: FieldKey, message: Segments.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -const toFieldMessage = - (field: FieldKey) => - (message: Segments.Message): Message => - Message.GotTimeFieldMessage({ fieldId: field, message }) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotTimeFieldMessage: ({ fieldId: field, message }) => - Update.foldChild({ - update: Segments.update, - read: (current: Model) => Option.some(current[field]), - write: (current: Model, next: Segments.Model): Model => ({ ...current, [field]: next }), - toParentMessage: toFieldMessage(field), - })(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const common = (field: FieldKey) => ({ - model: model[field], - toParentMessage: toFieldMessage(field), - interaction: interaction.wiring(model), - }) - return galleryFrame(h, "Time field", [ - gallerySection(h, "Fields", [ - ...dateField(h, common("start"), (f) => [f.label(["Start time"]), f.dateInput()]), - ...dateField(h, common("reminder"), (f) => [f.label(["Reminder"]), f.dateInput()]), - ...dateField(h, common("disabled"), (f) => [f.label(["Disabled"]), f.dateInput()]), - ]), - ]) -} - -export const gallery = defineGallery("Time field", { - Model, - init: () => ({ - model: { - start: Segments.init({ id: "start", kind: "time", value: "22:00" }), - reminder: Segments.init({ id: "reminder", kind: "time" }), - disabled: Segments.init({ id: "disabled", kind: "time", value: "08:30", isDisabled: true }), - interaction: Interaction.init(), - }, - }), - update, - view, - subscriptions: interaction.subscriptions, -}) diff --git a/apps/web-foldkit/src/gallery/entries/timezone-select.ts b/apps/web-foldkit/src/gallery/entries/timezone-select.ts deleted file mode 100644 index 5794501a8..000000000 --- a/apps/web-foldkit/src/gallery/entries/timezone-select.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Array, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as ComboBox from "../../ui/combo-box" -import * as TimezoneSelect from "../../ui/timezone-select" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ zones: Schema.Array(ComboBox.Model) }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotZoneMessage: { id: Schema.String, message: ComboBox.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldZoneOutMessage = ComboBox.OutMessage.match>({ - ChangedSelection: () => (model) => ({ model }), -}) - -const foldZone = (id: string) => - Update.foldChild({ - update: ComboBox.update, - read: (model: Model) => Array.findFirst(model.zones, (zone) => zone.id === id), - write: (model, nextZone) => - modifyFields(model, { zones: Array.map((zone) => (zone.id === id ? nextZone : zone)) }), - toParentMessage: (message) => Message.GotZoneMessage({ id, message }), - foldOutMessage: foldZoneOutMessage, - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame(h, "Timezone select", [ - gallerySection( - h, - "Timezone select", - Array.map(model.zones, (zone) => - h.submodel({ - slotId: zone.id, - model: zone, - view: TimezoneSelect.view, - viewInputs: TimezoneSelect.viewInputs({ className: "w-72" }), - toParentMessage: (message) => Message.GotZoneMessage({ id: zone.id, message }), - }), - ), - ), - ]) - -export const gallery = defineGallery("Timezone select", { - Model, - init: () => ({ - model: { - zones: [ - TimezoneSelect.init({ id: "berlin", value: "Europe/Berlin" }), - TimezoneSelect.init({ id: "empty" }), - ], - }, - }), - update: (model, message) => - Message.match>(message, { - GotZoneMessage: ({ id, message }) => foldZone(id)(model, message), - }), - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/toast.ts b/apps/web-foldkit/src/gallery/entries/toast.ts deleted file mode 100644 index 2ad6bf7b3..000000000 --- a/apps/web-foldkit/src/gallery/entries/toast.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Duration, Effect, Option, Schema } from "effect" -import { Command, Subscription, Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { ResolvedTheme, resolveSystemTheme } from "../../theme" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import * as Toast from "../../ui/toast" -import * as ToastView from "../../ui/toast-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - toasts: Toast.Model, - interaction: Interaction.Model, - theme: ResolvedTheme, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - ClickedExample: { label: Schema.String }, - CompletedSaveChanges: { id: Schema.Number }, - GotToastMessage: { message: Toast.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const toToastMessage = (message: Toast.Message) => Message.GotToastMessage({ message }) -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// COMMAND - -/** Stands in for `toast.promise(Promise.resolve(), ...)`, which settles after the loading toast paints. */ -const SaveGalleryChanges = Command.define("SaveGalleryChanges", { - args: { id: Schema.Number }, - messages: [Message.CompletedSaveChanges], - execute: ({ id }) => - Effect.sleep(Duration.millis(50)).pipe(Effect.as(Message.CompletedSaveChanges({ id }))), -}) - -// UPDATE - -// Same toasts as `apps/web/src/dev-gallery/entries/toast.tsx`; infinite durations keep captures stable. -const persistent = { duration: Number.POSITIVE_INFINITY } - -const examples: ReadonlyArray> = [ - { section: "Kinds", label: "Default", options: { ...persistent, title: "Message copied" } }, - { - section: "Kinds", - label: "Success", - options: { ...persistent, kind: "success", title: "Channel created" }, - }, - { - section: "Kinds", - label: "Error", - options: { ...persistent, kind: "error", title: "Couldn't send message" }, - }, - { section: "Kinds", label: "Info", options: { ...persistent, kind: "info", title: "You're offline" } }, - { - section: "Kinds", - label: "Warning", - options: { ...persistent, kind: "warning", title: "Storage almost full" }, - }, - { - section: "Content", - label: "With description", - options: { - ...persistent, - kind: "success", - title: "Invitation sent", - description: "ada@hazel.sh will get an email with a link to join.", - }, - }, - { - section: "Content", - label: "With action", - options: { ...persistent, title: "Message deleted", actionLabel: "Undo" }, - }, - { - section: "Async", - label: "Loading", - options: { ...persistent, kind: "loading", title: "Uploading attachment…" }, - }, - { - section: "Async", - label: "Promise", - options: { ...persistent, kind: "loading", title: "Saving changes…", isPromise: true }, - }, -] - -const withToasts = ( - model: Model, - result: Update.Return, - extra: ReadonlyArray> = [], -) => ({ - model: modifyFields(model, { toasts: () => result.model }), - commands: [...Command.mapMessages(result.commands, toToastMessage), ...extra], -}) - -const update = (model: Model, message: Message): Update.Return => - Message.match>(message, { - ClickedExample: ({ label }) => { - const example = examples.find((candidate) => candidate.label === label) - if (example === undefined) return { model } - const shown = Toast.show(model.toasts, example.options) - return withToasts( - model, - shown, - example.options.isPromise ? [SaveGalleryChanges({ id: shown.id })] : [], - ) - }, - CompletedSaveChanges: ({ id }) => - withToasts(model, Toast.show(model.toasts, { id, kind: "success", title: "Changes saved" })), - GotToastMessage: ({ message }) => { - const result = Toast.update(model.toasts, message) - return withToasts(model, { model: result.model, commands: result.commands }) - }, - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// SUBSCRIPTION - -const subscriptions = Subscription.aggregate()( - interaction.subscriptions, - Subscription.lift(Toast.subscriptions)({ - read: (model) => Option.some(model.toasts), - toParentMessage: toToastMessage, - }), -) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - const section = (title: string) => - gallerySection( - h, - title, - examples - .filter((example) => example.section === title) - .map((example) => - button( - h, - { - intent: "outline", - onPress: Message.ClickedExample({ label: example.label }), - interaction: { wiring, target: example.label }, - }, - [example.label], - ), - ), - ) - return galleryFrame( - h, - "Toast", - [section("Kinds"), section("Content"), section("Async")], - [ - h.submodel({ - slotId: "toaster", - model: model.toasts, - view: ToastView.view, - viewInputs: { theme: model.theme }, - toParentMessage: toToastMessage, - }), - ], - ) -} - -export const gallery = defineGallery("Toast", { - Model, - init: () => ({ - model: { toasts: Toast.init(), interaction: Interaction.init(), theme: resolveSystemTheme() }, - }), - update, - subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/toggle-group.ts b/apps/web-foldkit/src/gallery/entries/toggle-group.ts deleted file mode 100644 index ab56eb859..000000000 --- a/apps/web-foldkit/src/gallery/entries/toggle-group.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Option, Record, Schema } from "effect" -import { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { IconStar } from "../../icons" -import * as ToggleGroup from "../../ui/toggle-group" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Record(Schema.String, ToggleGroup.Model) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotToggleGroupMessage: { groupId: Schema.String, message: ToggleGroup.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldToggleGroup = (label: string) => - Update.foldChild({ - update: ToggleGroup.update, - read: (model: Model) => Record.get(model, label), - write: (model, nextGroup) => ({ ...model, [label]: nextGroup }), - toParentMessage: (message) => Message.GotToggleGroupMessage({ groupId: label, message }), - }) - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotToggleGroupMessage: ({ groupId: label, message }) => foldToggleGroup(label)(model, message), - }) - -// VIEW - -interface GroupSpec extends Omit { - readonly label: string - readonly selectionMode?: ToggleGroup.SelectionMode - readonly selectedKeys?: ReadonlyArray - readonly items: (h: HtmlBuilder) => ReadonlyArray -} - -const labelled = (entries: ReadonlyArray, disabledKey?: string) => () => - entries.map(([key, label]) => ({ key, content: [label], isDisabled: key === disabledKey })) - -const star = (h: HtmlBuilder): Html => IconStar(h) - -const sections: ReadonlyArray]> = [ - [ - "Single", - [ - { - label: "Alignment", - selectedKeys: ["center"], - items: labelled( - [ - ["left", "Left"], - ["center", "Center"], - ["right", "Right"], - ["justify", "Justify"], - ], - "justify", - ), - }, - ], - ], - [ - "Multiple", - [ - { - label: "Formatting", - selectionMode: "multiple", - selectedKeys: ["bold", "italic"], - items: labelled([ - ["bold", "Bold"], - ["italic", "Italic"], - ["underline", "Underline"], - ]), - }, - ], - ], - [ - "Vertical", - [ - { - label: "Density", - orientation: "vertical", - selectedKeys: ["compact"], - items: labelled([ - ["compact", "Compact"], - ["comfortable", "Comfortable"], - ["spacious", "Spacious"], - ]), - }, - { - label: "Layers", - orientation: "vertical", - selectionMode: "multiple", - selectedKeys: ["grid"], - items: labelled([ - ["grid", "Grid"], - ["guides", "Guides"], - ["rulers", "Rulers"], - ]), - }, - ], - ], - [ - "Sizes and circle", - [ - { - label: "Small", - size: "sm", - selectedKeys: ["day"], - items: labelled([ - ["day", "Day"], - ["week", "Week"], - ]), - }, - { - label: "Large", - size: "lg", - isCircle: true, - selectedKeys: ["month"], - items: labelled([ - ["month", "Month"], - ["year", "Year"], - ]), - }, - { - label: "Favorites", - size: "sq-sm", - selectionMode: "multiple", - isCircle: true, - items: (h) => [ - { key: "star", ariaLabel: "Star", content: [star(h)] }, - { key: "star-two", ariaLabel: "Star two", content: [star(h)] }, - ], - }, - ], - ], -] - -const view = (model: Model, h: HtmlBuilder) => - galleryFrame( - h, - "Toggle group", - sections.map(([title, groups]) => - gallerySection( - h, - title, - groups.flatMap(({ label, selectionMode: _mode, selectedKeys: _keys, items, ...viewInputs }) => - Option.match(Record.get(model, label), { - onNone: () => [], - onSome: (group) => [ - h.submodel({ - slotId: label, - model: group, - view: ToggleGroup.view, - viewInputs: { ...viewInputs, items: items(h) }, - toParentMessage: (message) => - Message.GotToggleGroupMessage({ groupId: label, message }), - }), - ], - }), - ), - ), - ), - ) - -export const gallery = defineGallery("Toggle group", { - Model, - init: () => ({ - model: Record.fromEntries( - sections.flatMap(([, groups]) => - groups.map( - ({ label, selectionMode, selectedKeys }) => - [label, ToggleGroup.init({ label, selectionMode, selectedKeys })] as const, - ), - ), - ), - }), - update, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/toggle.ts b/apps/web-foldkit/src/gallery/entries/toggle.ts deleted file mode 100644 index 37d27d942..000000000 --- a/apps/web-foldkit/src/gallery/entries/toggle.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Record, Schema } from "effect" -import type { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconStar } from "../../icons" -import * as Interaction from "../../ui/aria/interaction" -import { toggle, type ToggleOptions } from "../../ui/toggle" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ - selected: Schema.Record(Schema.String, Schema.Boolean), - interaction: Interaction.Model, -}) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - PressedToggle: { id: Schema.String }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const update = (model: Model, message: Message) => - Message.match>(message, { - PressedToggle: ({ id }) => ({ - model: modifyFields(model, { - selected: (selected) => ({ ...selected, [id]: !(selected[id] ?? false) }), - }), - }), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }) - -// VIEW - -const sizes = ["xs", "sm", "md", "lg"] as const -const squareSizes = ["sq-xs", "sq-sm", "sq-md", "sq-lg"] as const - -interface ToggleSpec extends Omit, "isSelected" | "onPress" | "interaction"> { - readonly id: string - readonly isSelected?: boolean - readonly content: (h: HtmlBuilder) => ReadonlyArray -} - -const star = (h: HtmlBuilder) => IconStar(h) - -const sections: ReadonlyArray]> = [ - [ - "Intents", - [ - { id: "plain", intent: "plain", content: () => ["Plain"] }, - { id: "outline", intent: "outline", content: () => ["Outline"] }, - { id: "plain-selected", intent: "plain", isSelected: true, content: () => ["Plain selected"] }, - { - id: "outline-selected", - intent: "outline", - isSelected: true, - content: () => ["Outline selected"], - }, - ], - ], - ["Sizes", sizes.map((size) => ({ id: size, intent: "outline", size, content: () => [`Size ${size}`] }))], - [ - "Square sizes", - squareSizes.map((size) => ({ - id: size, - intent: "outline", - size, - ariaLabel: `Star ${size}`, - content: (h) => [star(h)], - })), - ], - [ - "Circle and icon", - [ - { id: "circle", intent: "outline", isCircle: true, content: () => ["Circle"] }, - { id: "starred", intent: "outline", isSelected: true, content: (h) => [star(h), "Starred"] }, - ], - ], - [ - "Disabled", - [ - { id: "disabled", intent: "outline", isDisabled: true, content: () => ["Disabled"] }, - { - id: "disabled-selected", - intent: "outline", - isDisabled: true, - isSelected: true, - content: () => ["Disabled selected"], - }, - ], - ], -] - -const view = (model: Model, h: HtmlBuilder) => { - const wiring = interaction.wiring(model) - return galleryFrame( - h, - "Toggle", - sections.map(([title, toggles]) => - gallerySection( - h, - title, - toggles.map(({ id, isSelected: _isSelected, content, ...options }) => - toggle( - h, - { - ...options, - isSelected: Record.get(model.selected, id).pipe( - (found) => found._tag === "Some" && found.value, - ), - onPress: Message.PressedToggle({ id }), - interaction: { wiring, target: id }, - }, - content(h), - ), - ), - ), - ), - ) -} - -export const gallery = defineGallery("Toggle", { - Model, - init: () => ({ - model: { - selected: Record.fromEntries( - sections.flatMap(([, toggles]) => - toggles.map(({ id, isSelected }) => [id, isSelected ?? false] as const), - ), - ), - interaction: Interaction.init(), - }, - }), - update, - subscriptions: interaction.subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/toolbar.ts b/apps/web-foldkit/src/gallery/entries/toolbar.ts deleted file mode 100644 index 152c568c4..000000000 --- a/apps/web-foldkit/src/gallery/entries/toolbar.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { button } from "../../ui/button" -import { separator } from "../../ui/separator" -import * as Toolbar from "../../ui/toolbar" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ formatting: Toolbar.Model, tools: Toolbar.Model }) -type Model = typeof Model.Type -type Slot = keyof Model - -// MESSAGE - -const Message = defineMessageUnion({ - GotToolbarMessage: { slotId: Schema.Literals(["formatting", "tools"]), message: Toolbar.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldToolbar = (slot: Slot) => - Update.foldChild({ - update: Toolbar.update, - read: (model: Model) => Option.some(model[slot]), - write: (model, nextToolbar) => ({ ...model, [slot]: nextToolbar }), - toParentMessage: (message) => Message.GotToolbarMessage({ slotId: slot, message }), - }) - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotToolbarMessage: ({ slotId: slot, message }) => foldToolbar(slot)(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const toolbar = (slot: Slot, viewInputs: Toolbar.ViewInputs) => - h.submodel({ - slotId: slot, - model: model[slot], - view: Toolbar.view, - viewInputs, - toParentMessage: (message) => Message.GotToolbarMessage({ slotId: slot, message }), - }) - return galleryFrame(h, "Toolbar", [ - gallerySection(h, "Horizontal", [ - toolbar("formatting", { - content: [ - button(h, { intent: "plain", size: "sm" }, ["Bold"]), - button(h, { intent: "plain", size: "sm" }, ["Italic"]), - separator(h, { orientation: "vertical", className: "mx-1 h-5" }), - button(h, { intent: "plain", size: "sm" }, ["Link"]), - ], - }), - ]), - gallerySection(h, "Vertical", [ - toolbar("tools", { - content: [ - button(h, { intent: "outline", size: "sm" }, ["Select"]), - button(h, { intent: "outline", size: "sm" }, ["Draw"]), - ], - }), - ]), - ]) -} - -export const gallery = defineGallery("Toolbar", { - Model, - init: () => ({ - model: { - formatting: Toolbar.init({ label: "Text formatting" }), - tools: Toolbar.init({ label: "Tools", orientation: "vertical" }), - }, - }), - update, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/tooltip.ts b/apps/web-foldkit/src/gallery/entries/tooltip.ts deleted file mode 100644 index dce9fa301..000000000 --- a/apps/web-foldkit/src/gallery/entries/tooltip.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Array, Option, Schema } from "effect" -import { Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconEdit } from "../../icons" -import type { Placement } from "../../ui/aria/position" -import * as Interaction from "../../ui/aria/interaction" -import { button } from "../../ui/button" -import * as Tooltip from "../../ui/tooltip" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" -import { embedInteraction } from "../interaction" - -// MODEL - -const Model = Schema.Struct({ tooltips: Schema.Array(Tooltip.Model), interaction: Interaction.Model }) -type Model = typeof Model.Type - -const ids = ["top", "bottom", "right", "rename", "inverse", "no-arrow"] as const - -// MESSAGE - -const Message = defineMessageUnion({ - GotTooltipMessage: { id: Schema.String, message: Tooltip.Message }, - GotInteractionMessage: { message: Interaction.Message }, -}) -type Message = typeof Message.Type - -const interaction = embedInteraction((message) => Message.GotInteractionMessage({ message })) - -// UPDATE - -const foldTooltip = (id: string) => - Update.foldChild({ - update: Tooltip.update, - read: (model: Model) => Array.findFirst(model.tooltips, (tooltip) => tooltip.id === id), - write: (model, nextTooltip) => - modifyFields(model, { - tooltips: Array.map((tooltip) => (tooltip.id === id ? nextTooltip : tooltip)), - }), - toParentMessage: (message) => Message.GotTooltipMessage({ id, message }), - }) - -// VIEW - -type Example = Readonly<{ - id: (typeof ids)[number] - trigger: ( - h: HtmlBuilder, - wiring: Interaction.Wiring, - attributes: Parameters[0], - overlay: Html, - ) => Html - content: string - placement?: Placement - inverse?: boolean - arrow?: boolean -}> - -const outlineTrigger = - (label: string): Example["trigger"] => - (h, wiring, attributes, overlay) => - button(h, { intent: "outline", attributes, interaction: { wiring, target: label } }, [label, overlay]) - -const placementExamples: ReadonlyArray = [ - { id: "top", trigger: outlineTrigger("Top"), content: "Add reaction" }, - { id: "bottom", trigger: outlineTrigger("Bottom"), content: "Shown below", placement: "bottom" }, - { id: "right", trigger: outlineTrigger("Right"), content: "Shown to the right", placement: "right" }, -] - -const variantExamples: ReadonlyArray = [ - { - id: "rename", - trigger: (h, wiring, attributes, overlay) => - button( - h, - { - intent: "plain", - size: "sq-sm", - interaction: { wiring, target: "rename" }, - attributes: [...attributes, h.Attribute("aria-label", "Rename thread")], - }, - [IconEdit(h, { className: "size-4", attributes: { "data-slot": "icon" } }), overlay], - ), - content: "Rename", - }, - { id: "inverse", trigger: outlineTrigger("Inverse"), content: "Inverse tooltip", inverse: true }, - { id: "no-arrow", trigger: outlineTrigger("No arrow"), content: "Without an arrow", arrow: false }, -] - -const view = (model: Model, h: HtmlBuilder) => { - const example = (spec: Example) => - Option.match( - Array.findFirst(model.tooltips, (tooltip) => tooltip.id === spec.id), - { - onNone: () => h.empty, - onSome: (tooltip) => - h.submodel({ - slotId: spec.id, - model: tooltip, - view: Tooltip.view, - viewInputs: { - toTrigger: (attributes, overlay) => - spec.trigger(h, interaction.wiring(model), attributes, overlay), - content: [spec.content], - placement: spec.placement, - inverse: spec.inverse, - arrow: spec.arrow, - }, - toParentMessage: (message) => Message.GotTooltipMessage({ id: spec.id, message }), - }), - }, - ) - return galleryFrame(h, "Tooltip", [ - gallerySection(h, "Placement", Array.map(placementExamples, example)), - gallerySection(h, "Variants", Array.map(variantExamples, example)), - ]) -} - -export const gallery = defineGallery("Tooltip", { - Model, - init: () => ({ model: { tooltips: Array.map(ids, Tooltip.init), interaction: Interaction.init() } }), - update: (model, message) => - Message.match>(message, { - GotTooltipMessage: ({ id, message }) => foldTooltip(id)(model, message), - GotInteractionMessage: ({ message }) => interaction.fold(model, message), - }), - subscriptions: interaction.subscriptions, - view, -}) diff --git a/apps/web-foldkit/src/gallery/entries/tree.ts b/apps/web-foldkit/src/gallery/entries/tree.ts deleted file mode 100644 index a3caa5e15..000000000 --- a/apps/web-foldkit/src/gallery/entries/tree.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Option, Schema } from "effect" -import { Update } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { IconFolder, IconHashtag } from "../../icons" -import * as Tree from "../../ui/tree" -import * as TreeView from "../../ui/tree-view" -import { defineGallery } from "../define" -import { galleryFrame, gallerySection } from "../frame" - -// MODEL - -const Model = Schema.Struct({ channels: Tree.Model }) -type Model = typeof Model.Type - -// MESSAGE - -const Message = defineMessageUnion({ - GotTreeMessage: { message: Tree.Message }, -}) -type Message = typeof Message.Type - -// UPDATE - -const foldTree = Update.foldChild({ - update: Tree.update, - read: (model: Model) => Option.some(model.channels), - write: (model, nextTree) => modifyFields(model, { channels: () => nextTree }), - toParentMessage: (message) => Message.GotTreeMessage({ message }), -}) - -const update = (model: Model, message: Message) => - Message.match>(message, { - GotTreeMessage: ({ message }) => foldTree(model, message), - }) - -// VIEW - -const view = (model: Model, h: HtmlBuilder) => { - const folder = (label: string) => [IconFolder(h), label] - const channel = (label: string) => [IconHashtag(h), label] - const leaf = (key: string, isDisabled = false): TreeView.TreeNode => ({ - key, - textValue: key, - content: channel(key), - isDisabled, - }) - return galleryFrame(h, "Tree", [ - gallerySection(h, "Channels", [ - h.submodel({ - slotId: "channels", - model: model.channels, - view: TreeView.view, - viewInputs: { - label: "Channels", - className: "w-72", - nodes: [ - { - key: "engineering", - textValue: "Engineering", - content: folder("Engineering"), - children: [leaf("frontend"), leaf("backend"), leaf("archived", true)], - }, - { - key: "design", - textValue: "Design", - content: folder("Design"), - children: [leaf("research")], - }, - leaf("general"), - ], - }, - toParentMessage: (message) => Message.GotTreeMessage({ message }), - }), - ]), - ]) -} - -export const gallery = defineGallery("Tree", { - Model, - init: () => ({ model: { channels: Tree.init({ id: "tree-channels", expandedKeys: ["engineering"] }) } }), - update, - view, -}) diff --git a/apps/web-foldkit/src/gallery/frame.ts b/apps/web-foldkit/src/gallery/frame.ts deleted file mode 100644 index 7cf9c213f..000000000 --- a/apps/web-foldkit/src/gallery/frame.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Html, HtmlBuilder } from "foldkit/html" -import { galleryStyles } from "~/dev-gallery/gallery.styles" -import { emptyRegion } from "../ui/toast-view" - -/** - * Port of `apps/web/src/dev-gallery/frame.tsx`. The root is `div#app`, which the first render replaces. - * `before` renders ahead of the page, where the legacy root puts app-wide UI such as the toaster; - * by default the empty toaster region every legacy page has. - */ -export const galleryFrame = ( - h: HtmlBuilder, - title: string, - children: ReadonlyArray, - before: ReadonlyArray = [emptyRegion(h)], -): Html => - h.div( - [h.Id("app")], - [ - ...before, - h.div( - [h.Class(galleryStyles.page)], - [ - h.h1([h.Class(galleryStyles.title)], [title]), - h.div([h.Class(galleryStyles.sections)], [...children]), - ], - ), - ], - ) - -export const gallerySection = ( - h: HtmlBuilder, - title: string, - children: ReadonlyArray, -): Html => - h.section( - [h.Class(galleryStyles.section), h.Attribute("aria-label", title)], - [ - h.h2([h.Class(galleryStyles.sectionTitle)], [title]), - h.div([h.Class(galleryStyles.row)], [...children]), - ], - ) diff --git a/apps/web-foldkit/src/gallery/interaction.ts b/apps/web-foldkit/src/gallery/interaction.ts deleted file mode 100644 index 03ceee975..000000000 --- a/apps/web-foldkit/src/gallery/interaction.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Function } from "effect" -import * as Interaction from "../ui/aria/interaction" - -/** The interaction Submodel wiring every gallery entry repeats: fold, lifted subscriptions, view wiring. */ -export const embedInteraction = ( - toParentMessage: (message: Interaction.Message) => Message, -) => Interaction.embed(toParentMessage, Function.identity) diff --git a/apps/web-foldkit/src/page/README.md b/apps/web-foldkit/src/page/README.md index 521810441..da4a115e1 100644 --- a/apps/web-foldkit/src/page/README.md +++ b/apps/web-foldkit/src/page/README.md @@ -76,11 +76,6 @@ memoized views and a `sharedChanged` hook. `page/root/` shows a page that only r - **Performance.** The root re-renders on every Message. Memoize expensive subtrees with `createLazy` and keep `toParentMessage`-style functions at module scope (see `page/chat/channel/view.ts`). -## Parity - -Port the legacy class composition (`*.styles.ts`, the UI kit in `src/ui`), then run -`bun run parity build foldkit && bun run parity run --filter ` from `packages/ui-parity`. - ## Lint `bun run lint` (root) runs oxlint with `@foldkit/oxlint-plugin` (a devDependency of this app) on diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 633890932..1c50d6ab5 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -27,7 +27,7 @@ const parse = (path: string): AppRoute => onSome: (url) => urlToAppRoute(url), }) -/** Every legacy route (`bun run parity coverage`) and the Foldkit route it must resolve to. */ +/** Every legacy route and the Foldkit route it must resolve to. */ const legacyRoutes: ReadonlyArray = [ ["/", "Root"], ["/hazel", "OrgHome"], diff --git a/apps/web-foldkit/src/ui/toast-view.ts b/apps/web-foldkit/src/ui/toast-view.ts index 054f58fb1..d254e4bcc 100644 --- a/apps/web-foldkit/src/ui/toast-view.ts +++ b/apps/web-foldkit/src/ui/toast-view.ts @@ -155,9 +155,6 @@ const regionAttributes = (h: HtmlBuilder) => [ h.Attribute("aria-atomic", "false"), ] -/** sonner's region with no toast queued, for roots that host no toaster state (the gallery). */ -export const emptyRegion = (h: HtmlBuilder): Html => h.section(regionAttributes(h)) - /** The `
` sonner always renders, with the toast list while any toast is queued. */ export const view = Submodel.defineView((model, viewInputs, h) => h.section( diff --git a/bun.lock b/bun.lock index a24bdeae8..b880b7b37 100644 --- a/bun.lock +++ b/bun.lock @@ -574,24 +574,6 @@ "react": "^19.0.0", }, }, - "packages/ui-parity": { - "name": "@hazel/ui-parity", - "version": "0.0.0", - "dependencies": { - "@hazel/domain": "workspace:*", - "@hazel/schema": "workspace:*", - "effect": "catalog:effect", - "parse5": "^8", - "pixelmatch": "^7.1.0", - "playwright": "^1.56.0", - "pngjs": "^7.0.0", - }, - "devDependencies": { - "@types/bun": "1.3.9", - "@types/pngjs": "^6.0.5", - "typescript": "catalog:tooling", - }, - }, }, "catalogs": { "alchemy": { @@ -969,8 +951,6 @@ "@hazel/ui": ["@hazel/ui@workspace:packages/ui"], - "@hazel/ui-parity": ["@hazel/ui-parity@workspace:packages/ui-parity"], - "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -1731,8 +1711,6 @@ "@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], - "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], - "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], @@ -2885,14 +2863,6 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], - "pixelmatch": ["pixelmatch@7.2.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg=="], - - "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], - - "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], - - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], diff --git a/docs/foldkit-decisions/s1-skeleton.md b/docs/foldkit-decisions/s1-skeleton.md index 89c0ac47d..d6405d9f0 100644 --- a/docs/foldkit-decisions/s1-skeleton.md +++ b/docs/foldkit-decisions/s1-skeleton.md @@ -1,5 +1,7 @@ # S1: Skeleton spike +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: **passed** · 2026-10-07 · Branch `experiment-2.0` Question: can Foldkit boot Hazel's stack (Clerk auth, Effect RPC, Electric/TanStack DB) and render a real screen pixel-identical to the React app? diff --git a/docs/foldkit-decisions/s2-data.md b/docs/foldkit-decisions/s2-data.md index d53fa64cc..66b44cd19 100644 --- a/docs/foldkit-decisions/s2-data.md +++ b/docs/foldkit-decisions/s2-data.md @@ -1,5 +1,7 @@ # S2: Data layer spike +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: **decided: keep the TanStack DB bridge (option a)** · 2026-10-07 · Branch `fk/s4-chat-list` Question: should the Foldkit app keep Electric + the legacy TanStack DB collections and bridge live query results into the Model (a), or hold a normalized store of every synced row in the Model and update it from Electric shape changes (b)? Measured on the `heavy` dataset: 500 channels, 12,063 messages (10,000 in #firehose), 2,860 reactions, 48 users. diff --git a/docs/foldkit-decisions/s3-editor.md b/docs/foldkit-decisions/s3-editor.md index 1b92ac5c1..90995fef0 100644 --- a/docs/foldkit-decisions/s3-editor.md +++ b/docs/foldkit-decisions/s3-editor.md @@ -1,5 +1,7 @@ # S3: Editor spike +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: **passed** · 2026-10-07 · Branch `fk/s3-editor` Question: can a vanilla editor inside a Foldkit Mount replace the Slate composer with identical pixels, faithful markdown round-trips and working mention autocomplete? diff --git a/docs/foldkit-decisions/s4-chat-list.md b/docs/foldkit-decisions/s4-chat-list.md index 10cc02c70..a9f4b3274 100644 --- a/docs/foldkit-decisions/s4-chat-list.md +++ b/docs/foldkit-decisions/s4-chat-list.md @@ -1,5 +1,7 @@ # S4: Chat list spike +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: **passed (desktop)** · 2026-10-07 · Branch `fk/s4-chat-list` Question: can Foldkit render the channel screen pixel-identical to legacy with a bottom-anchored, virtualized message list that scrolls at 60fps through a 10k-message channel, keeps its position when older messages are prepended, and sticks to the bottom when a new message arrives? diff --git a/docs/foldkit-decisions/s4b-virtual-list.md b/docs/foldkit-decisions/s4b-virtual-list.md index 0ad3eef4f..166444688 100644 --- a/docs/foldkit-decisions/s4b-virtual-list.md +++ b/docs/foldkit-decisions/s4b-virtual-list.md @@ -1,5 +1,7 @@ # S4b: @foldkit/ui VirtualList for the chat list +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: **evaluated, not adopted** · 2026-10-08 · Branch `fk/foldkit-167` (experiment on `fk/foldkit-167-virtuallist`, commit `1ac2fb6da`, not for merge) Question: Foldkit 0.167's VirtualList now has measured dynamic row heights, stable-key viewport anchors, initial and programmatic scroll targets (Index, Key, Offset, End with alignment) and an end behavior (`Follow { thresholdPx }` or `PreserveAnchor`). Can it replace `mount/message-list.ts` and `mount/message-list-pool.ts` (S4 and the final-polish LegendList pool)? diff --git a/docs/foldkit-migration.md b/docs/foldkit-migration.md index 29d15263c..6c71b994e 100644 --- a/docs/foldkit-migration.md +++ b/docs/foldkit-migration.md @@ -1,5 +1,7 @@ # Foldkit migration plan +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Status: draft · Branch: `experiment-2.0` · Last updated: 2026-10-09 Goal: replace the React frontend (`apps/web`) with a Foldkit frontend (`apps/web-foldkit`) that looks pixel-identical to it. Users should notice nothing except speed and stability. Engineers should get a single Model, Messages as facts, and side effects that are testable. diff --git a/docs/foldkit-orchestrator-prompt.md b/docs/foldkit-orchestrator-prompt.md index 90a34733d..b95e8251b 100644 --- a/docs/foldkit-orchestrator-prompt.md +++ b/docs/foldkit-orchestrator-prompt.md @@ -1,5 +1,7 @@ # Orchestrator prompt: continue the Foldkit migration +> **Historical.** The legacy React app (`apps/web`) and the parity harness (`packages/ui-parity`, the `/dev/gallery` routes and the `legacy-ui-guard` agent) have been deleted. Parity commands and baselines referenced below no longer exist. + Paste the block below into a fresh Claude Code session at the repo root. ````text diff --git a/docs/foldkit-review/2026-10-full-audit.md b/docs/foldkit-review/2026-10-full-audit.md index 1e775a138..fea725334 100644 --- a/docs/foldkit-review/2026-10-full-audit.md +++ b/docs/foldkit-review/2026-10-full-audit.md @@ -113,7 +113,7 @@ Recurring Foldkit anti-patterns: boolean soup, nullable-means-loading, derived d - Keep hand-rolled virtual message list (Foldkit VirtualList still lacks sticky headers, forces list ARIA) — `s4b-virtual-list.md`. - Keep local Menu (submenus + context menu), Select, RadioGroup. -- `@foldkit/ui` migrations (Dialog/Popover/Tooltip/Toast/Animation) are blocked on `packages/ui-parity` pinning React Aria DOM. +- `@foldkit/ui` migrations (Dialog/Popover/Tooltip/Toast/Animation) were blocked on `packages/ui-parity` pinning React Aria DOM; the harness has since been deleted, so they are unblocked. Slider and Tabs could migrate now. ## Tooling diff --git a/package.json b/package.json index d6903cad5..3cb57fda7 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "test:once": "vitest run", "test:debug": "vitest --inspect-brk --no-file-parallelism", "test:coverage": "vitest run --coverage --coverage.reporter=text", - "parity": "bun run --cwd packages/ui-parity src/cli.ts", "alchemy:deploy:prd": "alchemy deploy --yes --adopt --stage ${HAZEL_STAGE:-prd}", "alchemy:plan:prd:local": "infisical run --env prod -- alchemy plan --adopt --stage prd", "alchemy:deploy:prd:local": "infisical run --env prod -- alchemy deploy --yes --adopt --stage prd", diff --git a/packages/ui-parity/.gitignore b/packages/ui-parity/.gitignore deleted file mode 100644 index 066450060..000000000 --- a/packages/ui-parity/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.parity/ diff --git a/packages/ui-parity/README.md b/packages/ui-parity/README.md deleted file mode 100644 index e1139c069..000000000 --- a/packages/ui-parity/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# @hazel/ui-parity - -Pixel-level comparison of the legacy React frontend (`apps/web`) against the Foldkit frontend (`apps/web-foldkit`). It is the acceptance test for the migration: a screen counts as ported when its scenarios come back `identical`. - -`apps/web` has been deleted from the working tree. The legacy side now only exists as the pinned baseline (`LEGACY_BASELINE_REF`), which `build legacy` checks out into a worktree. - -```bash -bun run parity build legacy # the pinned reference UI (LEGACY_BASELINE_REF in src/config.ts) -bun run parity build foldkit # build the Foldkit app from the working tree -bun run parity run # capture both, diff, write the report -``` - -## How it works - -``` - ┌──────────────── fixture backend (one Bun process) ───────────────┐ - │ :4790 /rpc real Effect RpcServer, handlers read the dataset │ - │ :4793 /v1/shape Electric shape protocol, rows from the dataset │ - └───────────────────────────────────────────────────────────────────┘ - ▲ ▲ - :4791 legacy build (static) :4792 foldkit build (static) - Clerk stub injected into index.html same stub, same env, same data - ▲ ▲ - └──── Playwright: same scenario, viewport, theme, clock ────┘ - │ - pixels + structure diff → report -``` - -- **Same data in both apps.** Both apps are built with `VITE_BACKEND_URL` / `VITE_ELECTRIC_URL` pointing at the fixture backend (`src/config.ts`). Datasets live in `src/fixtures/datasets/` as typed rows with deterministic IDs (`stableId("user:ada")`). -- **No Clerk network.** `src/runtime/clerk-stub.ts` defines `window.Clerk` before the app boots. `@clerk/react` sees a loaded instance and never downloads clerk-js, and `getToken()` returns a fixed token. The Foldkit app has to read auth through `window.Clerk` too (as `apps/web/src/lib/clerk-token.ts` already does) for the stub to apply. -- **One origin.** Every target is captured through `http://localhost:4800`, proxied to the target's port, so UI that prints `location.origin` matches across targets. Playwright fulfils that origin in-browser and nothing binds it. -- **Parallel runs.** Ports are offsets from `PARITY_PORT_BASE` (default `4790`): backend `+0`, legacy `+1`, foldkit `+2`, Electric `+3`. Give each worktree or agent its own base (`PARITY_PORT_BASE=4900 bun run parity …`, then 5000, 5100). Builds embed the backend URLs, so a non-default base builds into `.parity/builds-/` and `serve`/`run` refuse a build made for another base. Captures are byte-identical across bases. -- **Healthy captures only.** A capture that crashes, fails a step, or logs console errors (apart from known noise) marks its variant as `fail`, even when the pixels match. Two identical error screens are not parity. -- **Determinism.** Frozen clock (`dataset.now`), seeded `Math.random`, UTC, en-US, `deviceScaleFactor: 1`, animations and transitions frozen, carets and scrollbars hidden, service worker blocked, and every request outside the three local origins aborted. A capture waits for fonts and every image to decode, then for 400ms with no DOM mutations, then for two consecutive frames in which no scroll offset, scroll extent or image changes (late images make the chat list re-anchor). Anything that moved restarts the wait, up to the timeout. The screenshot is retaken until two in a row are identical, for motion only the compositor sees (the overscroll bounce after a wheel scroll). Fixture images are rendered when the backend starts, so the first capture of a run doesn't get them later than the rest. Infinite Web Animations (`element.animate`, motion's `repeat: Infinity`) are paused at the last frame of their first iteration, like the CSS freeze, and SMIL animations at t=0. -- **Behavior.** Capture tags every backend request with `x-parity-capture`, and the fixture backend logs each RPC (tag plus encoded payload) and each HTTP API request (method, path, body) per capture (`src/backend/call-log.ts`). The log as of the screenshot is stored in `.json` as `calls`. `compare` diffs the two logs (`src/behavior.ts`) and any difference fails the variant, with lines like `rpc: message.create payload differs at .content` or `rpc: channelMember.clearNotifications not sent (1× in baseline)` in `summary.md`: - - Order is ignored: calls are compared as a multiset per tag, because the two apps fire concurrent queries in different orders. - - Payload keys are sorted, and version-4 UUIDs (minted by the app with `crypto.randomUUID`) become ``. Fixture ids are version 5 (`stableId`) and are compared as is. - - `IGNORED_CALLS` (presence heartbeats, typing indicators) are never compared. `PRESENCE_ONLY_CALLS` (the presence status write on mount, Rivet actor metadata polling) only have to be sent by both apps or by neither, because their count depends on how long the capture ran. On `/dev/gallery/*` both lists are ignored: the Foldkit gallery boots a standalone program without the app root. -- **Accessibility.** Every capture stores `page.locator("body").ariaSnapshot()` as `aria` in `.json`. `compare` diffs the trees as multisets of lines (indentation stripped, so wrapper depth doesn't count) and `summary.md` ends with an "a11y deltas" section: differences grouped by role across the run, then per variant. It does not fail variants yet, so existing deltas can be triaged; `--strict-a11y` makes any ARIA difference a `fail`. -- **Two kinds of diff.** - - _Pixels_: strict (any channel differs) and perceptual (pixelmatch, anti-aliasing ignored). Differing pixels are grouped into regions. - - _Structure_: every visible text run and accessible control is recorded with its box and the computed styles that determine its look. Records are matched across apps by text or by role and name, never by DOM shape, because the two implementations won't share markup. Output reads like `font-weight: 600 → 500 on 18 text runs`, plus the elements that moved as a result. - -## Workflows - -### 1. Freeze the baseline (once, then whenever you deliberately change the reference) - -```bash -bun run parity build legacy --ref # worktree in ~/.cache/hazel-ui-parity, so main can keep moving -bun run parity selfcheck # captures legacy twice; must print "All variants deterministic." -``` - -Run `selfcheck` again after adding scenarios or datasets. A flaky scenario can't tell you whether the port is correct. - -### 2. Side-by-side browsing - -```bash -bun run parity serve # fixture backend + every built target -``` - -Open `http://localhost:4791` (legacy) and `http://localhost:4792` (Foldkit) next to each other, both signed in as Ada with the same workspace. Use this to check interactions the scenarios don't cover yet. The clock isn't frozen here, so relative times can differ. - -### 3. Port loop, one screen at a time (human or agent) - -```bash -bun run parity build foldkit && bun run parity run --filter settings-team -``` - -1. Read `.parity/runs//summary.md`. Root causes are listed first (grouped style deltas), then knock-on moves, then elements missing from or extra in Foldkit. -2. Fix the Foldkit view and repeat until the variant is `identical`. -3. When it's not obvious, open `index.html` in the same run folder. It has swipe, onion skin, blink, side-by-side and diff modes (keys `1`–`5`, `j`/`k` to step through). Click a red region to highlight the elements responsible. - -`bun run parity compare --run ` re-diffs an existing run's captures without capturing again (after changing compare or report code). - -`--filter` matches a variant id substring (`chat-channel--mobile`) or an area (`settings`). The exit code is non-zero if anything fails, so the loop can be scripted. - -### 4. Get the reference markup for a screen - -```bash -bun run parity capture legacy --filter settings-team --run ref -bun run parity codegen .parity/runs/ref/legacy/settings-team--desktop--light.html --line 120 --name orgHeader -``` - -Every capture also writes `.html`, an indented dump of the rendered DOM with sorted attributes. Diff the legacy and Foldkit dumps to find attribute-level differences. `codegen` turns any subtree into Foldkit `h(...)` code with class strings kept verbatim. For components, prefer porting the legacy `twMerge`/`tv` call itself (see `docs/foldkit-decisions/s1-skeleton.md`) and use codegen for one-off markup. - -### 5. Component gallery (UI kit primitives) - -`/dev/gallery/` renders every state of one primitive in both apps: - -- Legacy (pinned baseline only): `apps/web/src/dev-gallery/entries/.tsx` exports `title` and a `Gallery` component, picked up by a glob in `routes/dev/gallery/$component.tsx`. -- Foldkit: `apps/web-foldkit/src/gallery/entries/.ts` exports `gallery = defineGallery(title, { Model, init, update, view })`, a self-contained program that `entry.ts` boots instead of the app on that path. Wrap the view in `galleryFrame` and `gallerySection` (`gallery/frame.ts`), the ports of `dev-gallery/frame.tsx`. -- Scenarios go in `src/scenarios/gallery.ts`. Static states (variants, disabled, invalid) are rendered by the entry; interactive ones (hover, focus-visible, pressed, open) come from steps, so they test behavior too. - -### 6. Track what's left - -```bash -bun run parity coverage # every legacy route, ✓ if a scenario visits it -bun run parity list # every scenario × viewport × theme variant -``` - -### 7. Add a scenario - -Add an entry to the area file `src/scenarios/.ts` (an `AreaModule`: scenarios, the datasets the area introduces, and area-level canned RPCs). `src/scenarios.ts` only aggregates areas. Steps must use accessible locators (`getByRole`, `getByText`) so the same script drives both apps. If a step works in one app and not the other, that is a parity bug (wrong role or missing label), not a test bug. Add a dataset under `src/fixtures/datasets/` when a screen needs different data (empty org, long names, many unreads), and list it in your area module's `datasets`. - -If a capture logs `unmocked RPCs: ...`, add a canned response to your area module's `rpc`, or to `dataset.rpc` if it is dataset-specific. `defaultHandlers` in `src/backend/rpc.ts` is for handlers every screen needs. - -### 8. Measure performance - -```bash -bun run parity build legacy && bun run parity build foldkit -PARITY_PORT_BASE=4900 bun run src/bench/perf.ts --runs 5 --name baseline # from packages/ui-parity -PARITY_PORT_BASE=4900 bun run src/bench/perf.ts --only switch,scroll --runs 5 # a subset -``` - -`src/bench/perf.ts` runs legacy and foldkit side by side against the fixture backend (static builds, no dev server) and writes `.parity/perf//report.{json,md}` with p50 / p95 / max per metric. Groups (`--only`): - -- `scroll`: real wheel input (CDP mouseWheel, pointer over the list) at 60, 200 and 600 px per event, a fling to the top (history prepend) and a jump to the bottom, on the heavy dataset's 10k #firehose and its 60-message #general. Headless Chromium keeps a 60 Hz rAF even with `--disable-frame-rate-limit`, so frame cost comes from a DevTools timeline trace (`perf-trace.ts`): main-thread task time between consecutive animation frames, which must stay under 8.33 ms for 120 fps, split into script, style, layout, paint and GC. The rAF wall interval and blank frames (no row under the probe) are reported too. -- `switch`: sidebar click to the first visible row of the new channel painted, cold (first visit) and warm (revisit), on the heavy (500 channels, plus the 10k channel) and rich workspaces, with blank frames between click and content. -- `interact`: composer key to paint, command palette, delete-message modal, emoji picker, thread panel and image viewer, input event to the frame that shows them. -- `load`: first contentful paint, first message row, a TTI estimate and JS loaded; `sidebar`: wheel through 500 channels; `memory`: JS heap after the 10k channel and after 20 switches; `bundle`: all emitted JS, raw and gzip. - -In-page probes live in `perf-probes.ts` (`window.__perf`, injected before app code). `chat-list.ts` and `render-trace.ts` remain for list-specific digging. - -## Output - -`.parity/` (gitignored): - -- `builds//`: static builds -- `runs///.png|.json`: screenshots and snapshots (structure, `calls`, `aria`) -- `runs//diff/`: pixelmatch diff images -- `runs//summary.md`: agent-readable digest -- `runs//summary.json`: everything, machine-readable -- `runs//index.html`: interactive report - -## Known gaps - -- The legacy composer has role `combobox` and no accessible name, and the sidebar's drag handles all share the name "Drag". The Foldkit port should keep the roles visible users rely on, and the scenarios encode today's names. -- More legacy controls without a real accessible name, which the chat and navigation scenarios work around: message avatars are unnamed buttons unless the user has an uploaded avatar (scenarios open popovers from "Katherine Johnson" and "GitHub", whose images give the button a name); the sidebar section "+" buttons are all named "badge 13" (the icon's ``, indexed in section order); the mobile header menu button is "menu" and the bottom-nav one "menu Menu"; the image viewer has no dialog role. -- Legacy tooltips (React Aria) open on hover only after a pointer interaction, so tooltip scenarios click the channel heading first. The emoji picker (frimousse) downloads emoji data from a CDN; `seedEmojiPicker` puts a small fixed set in its storage cache instead. -- Third-party content is served by the fixture backend: `capture.ts` fulfils the link-preview worker (link previews, tweets), GIF CDNs and the YouTube player from `/net/<host>` (`backend/network.ts`, data in `dataset.network`), and `/rivet` stands in for the Rivet manager and message actor (`backend/rivet.ts`, scripts in `dataset.actors`). Still outside the fixture: GitHub PR and Linear URL embeds (HTTP API). Legacy has no unread divider (`isFirstNewMessage` is always false) and no channel members panel (the sidebar "Members" link goes to `/$orgSlug`). -- Fixture images (`/r2/*`, see `backend/assets.ts`) are flat colours. Captures with images still show sub-perceptual anti-aliasing noise at image and avatar edges between runs, which `selfcheck` tolerates (`pass`, 0%). -- Chromium only. Font rendering differs across OSes, so compare captures made on the same machine (or the same CI image), never a mix. -- Clerk's prebuilt `<SignIn>`, `<SignUp>` and `<CreateOrganization>` mount through `Clerk.mountSignIn` and friends, which the stub no-ops. The sign-in, sign-up, setup-organization and empty select-organization scenarios capture the surrounding page and the empty container, not the Clerk form. The Foldkit port mounts the same clerk-js components into the same container, so the form itself is Clerk's and stays out of scope. -- Signed-out scenarios use a dataset with `signedOut: true`: the stub installs Clerk with no session or user, and authenticated RPCs fail with `SessionNotProvidedError`. The app's Electric fetch then answers 401 locally for the collections it preloads; those console errors are expected there and ignored for signed-out datasets only. -- The onboarding timezone step runs infinite `motion` star animations. The Web Animations parts are frozen (see Determinism), but legacy animates the stars' `scale` from JavaScript every frame, so legacy captures still wait out the 8s quiet timeout. The frozen opacity is 0 in daytime, so the frame is deterministic. -- Electric live updates aren't simulated. Scenarios show steady state, and writes succeed without changing data. Optimistic-update visuals need dedicated datasets. diff --git a/packages/ui-parity/package.json b/packages/ui-parity/package.json deleted file mode 100644 index 33ba7220b..000000000 --- a/packages/ui-parity/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@hazel/ui-parity", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "parity": "bun run src/cli.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@hazel/domain": "workspace:*", - "@hazel/schema": "workspace:*", - "effect": "catalog:effect", - "parse5": "^8", - "pixelmatch": "^7.1.0", - "playwright": "^1.56.0", - "pngjs": "^7.0.0" - }, - "devDependencies": { - "@types/bun": "1.3.9", - "@types/pngjs": "^6.0.5", - "typescript": "catalog:tooling" - } -} diff --git a/packages/ui-parity/src/backend/assets.ts b/packages/ui-parity/src/backend/assets.ts deleted file mode 100644 index 67ee27ff3..000000000 --- a/packages/ui-parity/src/backend/assets.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createHash } from "node:crypto" -import { PNG } from "pngjs" -import { corsHeaders } from "./electric.ts" - -/** - * Fixture images under `/r2/*` (the `VITE_R2_PUBLIC_URL` base): attachments, avatars, custom emojis - * and embed icons. Each path renders a flat-colour PNG, so captures never depend on the network. - * Size comes from an optional `-<w>x<h>` suffix (`/r2/photo-640x400.png`), default 256x256. - */ - -const cache = new Map<string, Buffer>() - -const renderPng = (path: string, fallback: readonly [number, number] = [256, 256]): Buffer => { - const size = path.match(/-(\d{1,4})x(\d{1,4})\.\w+$/) - const width = Math.min(Number(size?.[1] ?? fallback[0]), 2048) - const height = Math.min(Number(size?.[2] ?? fallback[1]), 2048) - const hash = createHash("sha1").update(path).digest() - const png = new PNG({ width, height }) - // One flat colour per path: any resampling of a flat image gives the same pixels, so scaled - // avatars, emojis and thumbnails rasterize identically on every capture. - for (let offset = 0; offset < width * height * 4; offset += 4) { - png.data[offset] = hash[0]! - png.data[offset + 1] = hash[1]! - png.data[offset + 2] = hash[2]! - png.data[offset + 3] = 255 - } - return PNG.sync.write(png) -} - -export const isAssetRequest = (url: URL) => url.pathname.startsWith("/r2/") - -/** - * Renders every fixture image a dataset references before the first capture. Lazily rendered, the - * first capture of a run saw images trickle in (and legacy's chat list re-anchor) while later ones - * got them at once; warm, every capture of either app gets them at the same speed. - */ -export const warmAssets = (datasets: Iterable<{ readonly tables: unknown }>) => { - for (const dataset of datasets) { - for (const [path] of JSON.stringify(dataset.tables).matchAll( - /\/r2\/[^"\\?#\s]+\.(?:png|jpe?g|gif|webp)/gi, - )) { - if (!cache.has(path)) cache.set(path, renderPng(path)) - } - } - return cache.size -} - -export const handleAsset = (request: Request, url: URL): Response => { - if (!/\.(png|jpg|jpeg|gif|webp)$/i.test(url.pathname)) - return new Response("not found", { status: 404, headers: corsHeaders(request) }) - let body = cache.get(url.pathname) - if (!body) { - body = renderPng(url.pathname) - cache.set(url.pathname, body) - } - // Always PNG bytes; browsers sniff the content, whatever the extension says. - return new Response(new Uint8Array(body), { - headers: { ...corsHeaders(request), "content-type": "image/png", "cache-control": "no-store" }, - }) -} - -/** A flat-colour PNG for a third-party image URL (`backend/network.ts`), keyed by host and path. */ -export const fixtureImage = (request: Request, key: string, fallback: readonly [number, number]): Response => { - let body = cache.get(key) - if (!body) { - body = renderPng(key, fallback) - cache.set(key, body) - } - return new Response(new Uint8Array(body), { - headers: { ...corsHeaders(request), "content-type": "image/png", "cache-control": "no-store" }, - }) -} diff --git a/packages/ui-parity/src/backend/call-log.ts b/packages/ui-parity/src/backend/call-log.ts deleted file mode 100644 index e1f8f6ba0..000000000 --- a/packages/ui-parity/src/backend/call-log.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Behavioral parity: every RPC and HTTP API request the fixture backend receives, keyed by the - * capture that sent it (the `x-parity-capture` header `capture.ts` adds). `compare.ts` diffs the - * logs of the two apps. - */ - -export interface RecordedCall { - readonly kind: "rpc" | "http" - /** RPC tag (`message.create`) or `METHOD /path`. */ - readonly name: string - /** Encoded RPC payload, or the parsed (else raw) HTTP body; `null` when there is none. */ - readonly payload: unknown -} - -export const CAPTURE_HEADER = "x-parity-capture" -/** `GET` returns and forgets a capture's calls, `DELETE` forgets them (stragglers after the capture). */ -export const CALL_LOG_PATH = "/__parity/calls" - -const parseBody = (text: string): unknown => { - if (!text) return null - try { - return JSON.parse(text) - } catch { - return text - } -} - -export const makeCallLog = () => { - const byCapture = new Map<string, RecordedCall[]>() - const push = (capture: string, call: RecordedCall) => { - const calls = byCapture.get(capture) ?? [] - calls.push(call) - byCapture.set(capture, calls) - } - - /** RPC bodies are NDJSON client messages; only `Request` messages are calls. */ - const recordRpc = async (request: Request) => { - const capture = request.headers.get(CAPTURE_HEADER) - if (!capture || request.method !== "POST") return - for (const line of (await request.clone().text()).split("\n")) { - if (!line.trim()) continue - const message = parseBody(line) as { _tag?: string; tag?: string; payload?: unknown } - if (message?._tag === "Request" && message.tag) - push(capture, { kind: "rpc", name: message.tag, payload: message.payload ?? null }) - } - } - - const recordHttp = async (request: Request, url: URL) => { - const capture = request.headers.get(CAPTURE_HEADER) - if (!capture) return - const body = request.method === "GET" || request.method === "HEAD" ? "" : await request.clone().text() - push(capture, { - kind: "http", - name: `${request.method} ${url.pathname}${url.search}`, - payload: parseBody(body), - }) - } - - /** Serves `CALL_LOG_PATH`; returns undefined for any other request. */ - const handle = (request: Request, url: URL): Response | undefined => { - if (url.pathname !== CALL_LOG_PATH) return undefined - const capture = url.searchParams.get("capture") ?? "" - const calls = byCapture.get(capture) ?? [] - byCapture.delete(capture) - return Response.json(request.method === "GET" ? calls : []) - } - - return { recordRpc, recordHttp, handle } -} diff --git a/packages/ui-parity/src/backend/electric.ts b/packages/ui-parity/src/backend/electric.ts deleted file mode 100644 index 60ab07202..000000000 --- a/packages/ui-parity/src/backend/electric.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Dataset, Row, TableName } from "../fixtures/dataset.ts" -import { changesAfter, latestOffset, waitForChange } from "./live-events.ts" - -/** - * Serves Electric shape requests (`GET /v1/shape?table=...`) from a fixture - * dataset. Implements just enough of the protocol for the client to reach - * `up-to-date`: - * - * - initial request (`offset=-1`): every row as an insert + an up-to-date control message - * - live request (`live=true`): answered with up-to-date after a short pause. Holding it open - * would exhaust the browser's 6-connections-per-origin HTTP/1.1 pool and starve other shapes - * - subset snapshot requests (`where`/`limit`/...): the full table, unfiltered - * - * Values are encoded as Postgres text and typed through the `electric-schema` header, - * which is what the real Electric server does. - */ - -type PgType = "text" | "bool" | "int4" | "float8" | "jsonb" | "timestamptz" - -const pgTypeOf = (value: unknown): PgType | undefined => { - if (value === null || value === undefined) return undefined - if (value instanceof Date) return "timestamptz" - if (typeof value === "boolean") return "bool" - if (typeof value === "number") return Number.isInteger(value) ? "int4" : "float8" - if (typeof value === "object") return "jsonb" - return "text" -} - -const encodeValue = (value: unknown): string | null => { - if (value === null || value === undefined) return null - if (value instanceof Date) return value.toISOString() - if (typeof value === "object") return JSON.stringify(value) - return String(value) -} - -const schemaFor = (rows: ReadonlyArray<Row>) => { - const schema: Record<string, { type: PgType }> = {} - for (const row of rows) { - for (const [column, value] of Object.entries(row)) { - const type = pgTypeOf(value) - if (type && !schema[column]) schema[column] = { type } - } - } - // Columns that are null in every row still need a type for the timestamptz parser. - for (const row of rows) { - for (const column of Object.keys(row)) { - if (!schema[column]) schema[column] = { type: column.endsWith("At") ? "timestamptz" : "text" } - } - } - return schema -} - -const SHAPE_OFFSET = "0_0" -const LIVE_POLL_DELAY_MS = 150 - -export const corsHeaders = (request: Request): Record<string, string> => ({ - "access-control-allow-origin": request.headers.get("origin") ?? "*", - "access-control-allow-credentials": "true", - "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "*", - "access-control-allow-methods": "GET, POST, PUT, OPTIONS", - "access-control-expose-headers": - "electric-handle, electric-offset, electric-schema, electric-cursor, electric-up-to-date", -}) - -type ResponseKind = "initial" | "snapshot" | "live" - -const UP_TO_DATE = { headers: { control: "up-to-date", global_last_seen_lsn: "0" } } - -const encodeBody = (table: TableName | null, rows: ReadonlyArray<Row>, kind: ResponseKind) => { - if (kind === "live") return JSON.stringify([UP_TO_DATE]) - const changeMessages = rows.map((row) => ({ - key: `"public"."${table}"/"${String(row.id)}"`, - value: Object.fromEntries(Object.entries(row).map(([column, value]) => [column, encodeValue(value)])), - headers: { operation: "insert", relation: ["public", table] }, - })) - if (kind === "snapshot") - return JSON.stringify({ - metadata: { snapshot_mark: 0, xmin: "0", xmax: "0", xip_list: [], database_lsn: "0" }, - data: changeMessages, - }) - return JSON.stringify([...changeMessages, UP_TO_DATE]) -} - -/** Datasets are immutable, so each (dataset, table, kind) body and schema header is encoded once. */ -const encoded = new WeakMap<Dataset, Map<string, { readonly schema: string; readonly body: string }>>() - -const encodedShape = (dataset: Dataset, table: TableName | null, kind: ResponseKind) => { - let byKey = encoded.get(dataset) - if (!byKey) { - byKey = new Map() - encoded.set(dataset, byKey) - } - const key = `${table}:${kind}` - let entry = byKey.get(key) - if (!entry) { - const rows = (table && dataset.tables[table]) || [] - entry = { schema: JSON.stringify(schemaFor(rows)), body: encodeBody(table, rows, kind) } - byKey.set(key, entry) - } - return entry -} - -export const handleShape = (request: Request, dataset: Dataset): Response | Promise<Response> => { - const url = new URL(request.url) - const table = url.searchParams.get("table") as TableName | null - const isLive = url.searchParams.get("live") === "true" - const isSubsetSnapshot = ["where", "limit", "order_by", "subset__where", "subset__limit"].some((param) => - url.searchParams.has(param), - ) - const kind: ResponseKind = isLive ? "live" : isSubsetSnapshot ? "snapshot" : "initial" - const { schema, body } = encodedShape(dataset, table, kind) - const base = { - ...corsHeaders(request), - "content-type": "application/json", - "cache-control": "no-store", - "electric-handle": `parity-${dataset.name}-${table}`, - "electric-offset": SHAPE_OFFSET, - "electric-schema": schema, - } - const upToDate = { ...base, "electric-up-to-date": "", "electric-cursor": "0" } - - if (kind === "live") { - // Pushed live events (see live-events.ts) are delivered here; with none, this is the plain poll. - const pushed = () => changesAfter(dataset.name, table ?? "", url.searchParams.get("offset")) - const respond = () => { - const { messages, offset } = pushed() - const headers = { ...upToDate, "electric-offset": offset } - return messages.length === 0 - ? new Response(body, { headers }) - : new Response(JSON.stringify([...messages, UP_TO_DATE]), { headers }) - } - return pushed().messages.length > 0 - ? respond() - : waitForChange(dataset.name, table ?? "", LIVE_POLL_DELAY_MS).then(respond) - } - // Scenarios that push from an RPC must not leak those writes into the next page load. - const initialOffset = { ...upToDate, "electric-offset": latestOffset(dataset.name, table ?? "") } - return new Response(body, { headers: kind === "snapshot" ? base : initialOffset }) -} diff --git a/packages/ui-parity/src/backend/live-events.ts b/packages/ui-parity/src/backend/live-events.ts deleted file mode 100644 index cb22bd84e..000000000 --- a/packages/ui-parity/src/backend/live-events.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { TableName } from "../fixtures/dataset.ts" - -/** - * Fixture-driven live events: `POST /__parity/push` appends a change to a per-(dataset, table) log, - * and the next live shape request delivers it (a waiting long-poll is answered at once). With an - * empty log, live requests behave exactly as before. Values are pushed already Postgres-encoded. - */ - -export interface PushedChange { - readonly table: TableName - readonly operation: "insert" | "update" | "delete" - readonly row: Readonly<Record<string, string | null>> -} - -interface LoggedChange { - readonly seq: number - readonly message: unknown -} - -const logs = new Map<string, LoggedChange[]>() -const waiters = new Map<string, Set<() => void>>() -let seq = 0 - -const logKey = (datasetName: string, table: string) => `${datasetName}:${table}` - -export const pushChange = (datasetName: string, change: PushedChange) => { - const key = logKey(datasetName, change.table) - const log = logs.get(key) ?? [] - seq += 1 - log.push({ - seq, - message: { - key: `"public"."${change.table}"/"${String(change.row.id)}"`, - value: change.operation === "delete" ? { id: change.row.id } : change.row, - headers: { operation: change.operation, relation: ["public", change.table] }, - }, - }) - logs.set(key, log) - for (const wake of waiters.get(key) ?? []) wake() - waiters.delete(key) - return seq -} - -/** The newest pushed seq as an offset: a fresh shape starts from the dataset, after earlier pushes. */ -export const latestOffset = (datasetName: string, table: string) => - `${logs.get(logKey(datasetName, table))?.at(-1)?.seq ?? 0}_0` - -/** Changes after the client's offset (`<seq>_0`), plus the offset to report back. */ -export const changesAfter = (datasetName: string, table: string, offset: string | null) => { - const clientSeq = Math.max(0, Number.parseInt(offset?.split("_")[0] ?? "0", 10) || 0) - const log = logs.get(logKey(datasetName, table)) ?? [] - const pending = log.filter((change) => change.seq > clientSeq) - const latest = pending.at(-1)?.seq ?? clientSeq - return { messages: pending.map((change) => change.message), offset: `${latest}_0` } -} - -/** Resolves when a change is pushed for this table, or after `timeoutMs`. */ -export const waitForChange = (datasetName: string, table: string, timeoutMs: number) => - new Promise<void>((resolve) => { - const key = logKey(datasetName, table) - const set = waiters.get(key) ?? new Set() - const wake = () => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - set.delete(wake) - resolve() - }, timeoutMs) - set.add(wake) - waiters.set(key, set) - }) diff --git a/packages/ui-parity/src/backend/network.ts b/packages/ui-parity/src/backend/network.ts deleted file mode 100644 index 9a8561a0d..000000000 --- a/packages/ui-parity/src/backend/network.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Dataset } from "../fixtures/dataset.ts" -import { fixtureImage } from "./assets.ts" -import { corsHeaders } from "./electric.ts" - -/** - * Third-party services message content reaches, served from the dataset so captures never touch - * the network: the link-preview worker (unfurls and tweets), GIF CDNs and the YouTube player. - * `capture.ts` fulfils requests to these hosts from `/net/<host><path>` on the fixture backend. - */ - -export const NETWORK_FIXTURE_HOSTS: ReadonlySet<string> = new Set([ - "link-preview.hazel.sh", - "media.giphy.com", - "media0.giphy.com", - "media1.giphy.com", - "media2.giphy.com", - "media3.giphy.com", - "media4.giphy.com", - "i.giphy.com", - "static.klipy.com", - "www.youtube.com", -]) - -export const NETWORK_PREFIX = "/net/" - -/** Where the fixture backend serves a request to one of `NETWORK_FIXTURE_HOSTS`. */ -export const networkFixturePath = (url: URL) => `${NETWORK_PREFIX}${url.hostname}${url.pathname}${url.search}` - -export const isNetworkRequest = (url: URL) => url.pathname.startsWith(NETWORK_PREFIX) - -const notFound = (request: Request) => - // 404 is the console noise the harness already ignores; the apps treat any failure the same. - new Response("not found", { status: 404, headers: corsHeaders(request) }) - -const json = (request: Request, body: unknown) => - Response.json(body, { headers: { ...corsHeaders(request), "cache-control": "no-store" } }) - -/** A stand-in for the embedded YouTube player: a flat frame naming the video. */ -const youtubePlayer = (videoId: string) => - `<!doctype html><html><head><meta charset="utf-8"><title>YouTube` + - `` + - `
` + - `
` + - `
` + - `
${videoId.replace(/[^\w-]/g, "")}
` - -export const handleNetwork = (request: Request, url: URL, dataset: Dataset): Response => { - const rest = url.pathname.slice(NETWORK_PREFIX.length) - const slash = rest.indexOf("/") - const host = slash === -1 ? rest : rest.slice(0, slash) - const path = slash === -1 ? "/" : rest.slice(slash) - - if (host === "link-preview.hazel.sh") { - if (path === "/link-preview") { - const preview = dataset.network?.linkPreviews?.[url.searchParams.get("url") ?? ""] - return preview === undefined ? notFound(request) : json(request, preview) - } - if (path === "/tweet") { - const tweet = dataset.network?.tweets?.[url.searchParams.get("id") ?? ""] - return tweet === undefined ? notFound(request) : json(request, tweet) - } - return notFound(request) - } - if (host === "www.youtube.com") { - const videoId = path.match(/^\/embed\/([^/?#]+)/)?.[1] - return videoId === undefined - ? notFound(request) - : new Response(youtubePlayer(videoId), { - headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, - }) - } - // GIF CDNs: a flat image per URL; a `-x` suffix on the file name sets the size. - return fixtureImage(request, `/net/${host}${path}`, [480, 270]) -} diff --git a/packages/ui-parity/src/backend/rivet.ts b/packages/ui-parity/src/backend/rivet.ts deleted file mode 100644 index 246829ec6..000000000 --- a/packages/ui-parity/src/backend/rivet.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { ServerWebSocket } from "bun" -import type { ActorScript, Dataset } from "../fixtures/dataset.ts" -import { corsHeaders } from "./electric.ts" - -/** - * A stand-in for the Rivet manager and the `message` actor (`VITE_RIVET_URL` is `/rivet`), - * speaking RivetKit 2.1's client protocol: `GET /metadata`, `PUT /actors` (getOrCreate), and the - * `/gateway//connect` WebSocket with BARE framing and CBOR payloads. Each message's actor - * answers `getState` with its script's snapshot, then streams the script's events. - */ - -export const RIVET_PREFIX = "/rivet/" -export const isRivetRequest = (url: URL) => url.pathname.startsWith(RIVET_PREFIX) - -export interface RivetSocketData { - readonly datasetName: string - readonly messageId: string -} - -// MARK: CBOR (the subset event payloads use: maps, arrays, strings, numbers, booleans, null) - -const cborHead = (major: number, length: number): number[] => { - if (length < 24) return [(major << 5) | length] - if (length < 0x100) return [(major << 5) | 24, length] - if (length < 0x10000) return [(major << 5) | 25, length >> 8, length & 0xff] - return [(major << 5) | 26, (length >>> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff] -} - -const encodeCbor = (value: unknown): number[] => { - if (value === null || value === undefined) return [0xf6] - if (value === false) return [0xf4] - if (value === true) return [0xf5] - if (typeof value === "number") { - if (Number.isInteger(value) && Math.abs(value) < 2 ** 32) - return value >= 0 ? cborHead(0, value) : cborHead(1, -1 - value) - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, value) - return [0xfb, ...new Uint8Array(view.buffer)] - } - if (typeof value === "string") { - const bytes = new TextEncoder().encode(value) - return [...cborHead(3, bytes.length), ...bytes] - } - if (Array.isArray(value)) return [...cborHead(4, value.length), ...value.flatMap(encodeCbor)] - const entries = Object.entries(value as Record).filter(([, item]) => item !== undefined) - return [...cborHead(5, entries.length), ...entries.flatMap(([key, item]) => [...encodeCbor(key), ...encodeCbor(item)])] -} - -// MARK: BARE (client-protocol v3, with vbare's little-endian u16 version prefix) - -const PROTOCOL_VERSION = 3 - -const uvarint = (value: number): number[] => { - const bytes: number[] = [] - let rest = value - while (rest >= 0x80) { - bytes.push((rest & 0x7f) | 0x80) - rest = Math.floor(rest / 0x80) - } - bytes.push(rest) - return bytes -} -const bareData = (bytes: ArrayLike) => [...uvarint(bytes.length), ...Array.from(bytes)] -const bareString = (text: string) => bareData(new TextEncoder().encode(text)) -const frame = (body: number[]) => new Uint8Array([PROTOCOL_VERSION & 0xff, PROTOCOL_VERSION >> 8, ...body]) - -const initMessage = (actorId: string, connectionId: string) => - frame([0, ...bareString(actorId), ...bareString(connectionId)]) -const actionResponse = (id: number, output: unknown) => frame([2, ...uvarint(id), ...bareData(encodeCbor(output))]) -// Event args are the listener's arguments: one payload object. -const eventMessage = (name: string, payload: unknown) => - frame([3, ...bareString(name), ...bareData(encodeCbor([payload]))]) - -class Cursor { - offset = 2 - constructor(readonly bytes: Uint8Array) {} - u8() { - return this.bytes[this.offset++]! - } - uvarint() { - let value = 0 - let scale = 1 - for (;;) { - const byte = this.u8() - value += (byte & 0x7f) * scale - if (byte < 0x80) return value - scale *= 0x80 - } - } - string() { - const length = this.uvarint() - const text = new TextDecoder().decode(this.bytes.subarray(this.offset, this.offset + length)) - this.offset += length - return text - } -} - -/** `ActionRequest` frames from the client (subscriptions need no answer: every event is sent). */ -const readActionRequest = (bytes: Uint8Array): { id: number; name: string } | null => { - const cursor = new Cursor(bytes) - if (cursor.u8() !== 0) return null - return { id: cursor.uvarint(), name: cursor.string() } -} - -// MARK: HTTP + WebSocket - -/** Actor ids carry the dataset, since the WebSocket upgrade bypasses the capture's request headers. */ -const actorIdOf = (datasetName: string, messageId: string) => `parity.${datasetName}.${messageId}` -const parseActorId = (actorId: string): RivetSocketData | null => { - const match = actorId.match(/^parity\.([\w-]+)\.([\w-]+)$/) - return match ? { datasetName: match[1]!, messageId: match[2]! } : null -} - -const json = (request: Request, body: unknown, status = 200) => - Response.json(body, { status, headers: { ...corsHeaders(request), "cache-control": "no-store" } }) - -export const handleRivet = async ( - request: Request, - url: URL, - dataset: Dataset, - upgrade: (data: RivetSocketData) => boolean, -): Promise => { - const path = url.pathname.slice(RIVET_PREFIX.length - 1) - if (path === "/metadata") return json(request, { runtime: "rivetkit", version: "2.1.6", actorNames: {} }) - if (path === "/actors" && request.method === "PUT") { - const body: { name: string; key: string } = await request.json() - return json(request, { - actor: { - actor_id: actorIdOf(dataset.name, body.key), - name: body.name, - key: body.key, - namespace_id: "parity", - runner_name_selector: "default", - create_ts: dataset.now.getTime(), - connectable_ts: dataset.now.getTime(), - start_ts: dataset.now.getTime(), - }, - created: false, - }) - } - const gateway = path.match(/^\/gateway\/([^/@]+)(?:@[^/]*)?\/connect$/) - if (gateway) { - const data = parseActorId(decodeURIComponent(gateway[1]!)) - if (data && upgrade(data)) return undefined - return json(request, { group: "actor", code: "not_found" }, 404) - } - return json(request, { group: "parity", code: "not_found" }, 404) -} - -export const makeRivetSocketHandlers = (datasets: ReadonlyMap) => { - const scriptOf = (data: RivetSocketData): ActorScript | undefined => - datasets.get(data.datasetName)?.actors?.[data.messageId] - const timers = new WeakMap, Array>>() - return { - open: (socket: ServerWebSocket) => { - socket.send(initMessage(actorIdOf(socket.data.datasetName, socket.data.messageId), "parity-conn")) - }, - message: (socket: ServerWebSocket, raw: string | Buffer) => { - if (typeof raw === "string") return - const request = readActionRequest(new Uint8Array(raw)) - if (request === null) return - const script = scriptOf(socket.data) - socket.send(actionResponse(request.id, request.name === "getState" ? (script?.state ?? null) : null)) - if (request.name !== "getState" || script === undefined) return - // Events follow the snapshot one by one, like tokens arriving from the model. - timers.set( - socket, - script.events.map((event, index) => - setTimeout(() => socket.send(eventMessage(event.name, event.payload)), 20 * (index + 1)), - ), - ) - }, - close: (socket: ServerWebSocket) => { - for (const timer of timers.get(socket) ?? []) clearTimeout(timer) - }, - } -} diff --git a/packages/ui-parity/src/backend/rpc.ts b/packages/ui-parity/src/backend/rpc.ts deleted file mode 100644 index 7cc0aebb9..000000000 --- a/packages/ui-parity/src/backend/rpc.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - AttachmentRpcs, - AuthMiddleware, - BotRpcs, - ChannelMemberRpcs, - ChannelRpcs, - ChannelSectionRpcs, - ChannelWebhookRpcs, - ChatSyncRpcs, - ConnectShareRpcs, - CustomEmojiRpcs, - GitHubSubscriptionRpcs, - IntegrationRequestRpcs, - MessageReactionRpcs, - MessageRpcs, - NotificationRpcs, - OrganizationMemberRpcs, - OrganizationRpcs, - PinnedMessageRpcs, - RssSubscriptionRpcs, - ScopeInjectionMiddleware, - TypingIndicatorRpcs, - UserPresenceStatusRpcs, - UserRpcs, -} from "@hazel/domain/rpc" -import { CurrentUser, SessionNotProvidedError } from "@hazel/domain" -import { TypingIndicator, UserPresenceStatus } from "@hazel/domain/models" -import { - ChatSyncConnectionListResponse, - TypingIndicatorResponse, - UserPresenceStatusResponse, -} from "@hazel/domain/rpc" -import type { TransactionId } from "@hazel/schema" -import { Effect, Layer } from "effect" -import { HttpRouter } from "effect/http" -import { RpcSerialization, RpcServer } from "effect/rpc" -import type { Dataset } from "../fixtures/dataset.ts" -import { areaRpcHandlers } from "../scenarios.ts" -import { stableId } from "../fixtures/ids.ts" - -/** - * Fixture RPC backend: the real Effect RPC server (same groups, same NDJSON - * serialization as `apps/backend`), but every handler answers from the dataset. - * Encoding is therefore guaranteed to match what the frontends decode. - */ - -const AllRpcs = MessageRpcs.merge( - MessageReactionRpcs, - NotificationRpcs, - IntegrationRequestRpcs, - TypingIndicatorRpcs, - PinnedMessageRpcs, - OrganizationRpcs, - OrganizationMemberRpcs, - UserRpcs, - UserPresenceStatusRpcs, -) - .merge( - ChannelRpcs, - ChannelMemberRpcs, - ChannelSectionRpcs, - ChannelWebhookRpcs, - ConnectShareRpcs, - GitHubSubscriptionRpcs, - RssSubscriptionRpcs, - AttachmentRpcs, - BotRpcs, - CustomEmojiRpcs, - ) - .merge(ChatSyncRpcs) - .middleware(ScopeInjectionMiddleware) - -export interface RpcLog { - readonly unmocked: Set -} - -const FIXTURE_TRANSACTION_ID = 1 as TransactionId - -/** - * Reads every screen needs, plus the background writes the app fires on its own - * (presence heartbeats, clearing unread counts). Writes succeed without changing - * fixture data, so captures stay identical. - */ -const defaultHandlers = (dataset: Dataset): Record unknown> => ({ - "user.me": () => new CurrentUser.Schema(dataset.currentUser), - "userPresenceStatus.heartbeat": () => ({ lastSeenAt: dataset.now }), - "userPresenceStatus.update": () => { - const row = dataset.tables.user_presence_status?.find( - (presence) => presence.userId === dataset.currentUser.id, - ) - return new UserPresenceStatusResponse({ - data: UserPresenceStatus.Schema.make(row as never), - transactionId: FIXTURE_TRANSACTION_ID, - }) - }, - "channelMember.clearNotifications": () => ({ transactionId: FIXTURE_TRANSACTION_ID }), - // Typing indicators are echoed back but never synced, so no indicator appears in captures. - "typingIndicator.create": (payload) => { - const { channelId, memberId, lastTyped } = payload as { - channelId: string - memberId: string - lastTyped: number - } - return new TypingIndicatorResponse({ - data: TypingIndicator.Schema.make({ - id: stableId(`typing:${memberId}`), - channelId, - memberId, - lastTyped, - } as never), - transactionId: FIXTURE_TRANSACTION_ID, - }) - }, - // Deleting echoes the indicator back; it was never synced, so nothing changes on screen. - "typingIndicator.delete": (payload) => { - const { id } = payload as { id: string } - const channelId = dataset.tables.channels?.[0]?.id ?? id - return new TypingIndicatorResponse({ - data: TypingIndicator.Schema.make({ id, channelId, memberId: id, lastTyped: 0 } as never), - transactionId: FIXTURE_TRANSACTION_ID, - }) - }, - "organization.getBySlugPublic": () => null, - "chatSync.connection.list": () => new ChatSyncConnectionListResponse({ data: [] }), - // Per-area handlers (`src/scenarios/.ts`), then the dataset's own overrides. - ...areaRpcHandlers(dataset), - ...dataset.rpc, -}) - -export const makeRpcWebHandler = (dataset: Dataset, log: RpcLog) => { - const canned = defaultHandlers(dataset) - const handlers = Object.fromEntries( - [...AllRpcs.requests.keys()].map((tag) => [ - tag, - (payload: unknown) => { - const respond = canned[tag] - // A handler may return an Effect (e.g. `Effect.fail(new SomeTypedError(...))`) to fail with a typed error. - if (respond) - return Effect.suspend(() => { - const result = respond(payload) - return Effect.isEffect(result) ? result : Effect.succeed(result) - }) - log.unmocked.add(tag) - return Effect.die(`ui-parity: no fixture for RPC "${tag}"`) - }, - ]), - ) - - const HandlersLive = AllRpcs.toLayer(handlers as never) - - const AuthLive = Layer.succeed( - AuthMiddleware, - AuthMiddleware.of((effect) => - // Signed-out datasets send no bearer token, so authenticated RPCs fail like the real backend. - dataset.signedOut - ? Effect.fail( - new SessionNotProvidedError({ - message: "No session", - detail: "ui-parity: signed out", - }), - ) - : Effect.provideService( - effect, - CurrentUser.Context, - new CurrentUser.Schema(dataset.currentUser), - ), - ), - ) - const ScopesLive = Layer.succeed( - ScopeInjectionMiddleware, - ScopeInjectionMiddleware.of((effect) => effect), - ) - - const RpcRoute = RpcServer.layerHttp({ group: AllRpcs, path: "/rpc", protocol: "http" }).pipe( - Layer.provide([HandlersLive, AuthLive, ScopesLive, RpcSerialization.layerNdjson]), - ) - - return HttpRouter.toWebHandler(RpcRoute as never, { disableLogger: true }) -} diff --git a/packages/ui-parity/src/backend/server.ts b/packages/ui-parity/src/backend/server.ts deleted file mode 100644 index 4ed9137a5..000000000 --- a/packages/ui-parity/src/backend/server.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Dataset } from "../fixtures/dataset.ts" -import { handleAsset, isAssetRequest, warmAssets } from "./assets.ts" -import { makeCallLog } from "./call-log.ts" -import { corsHeaders, handleShape } from "./electric.ts" -import { type PushedChange, pushChange } from "./live-events.ts" -import { handleNetwork, isNetworkRequest } from "./network.ts" -import { handleRivet, isRivetRequest, makeRivetSocketHandlers, type RivetSocketData } from "./rivet.ts" -import { makeRpcWebHandler, type RpcLog } from "./rpc.ts" - -/** - * One local process that stands in for the whole backend: Effect RPC at `/rpc` on - * one port, Electric shapes at `/v1/shape` on another. Both frontends are built against this URL, so - * they can be captured by Playwright or opened side by side in a normal browser. - */ -export const startFixtureBackend = (options: { - readonly port: number - readonly electricPort: number - readonly datasets: ReadonlyMap - readonly defaultDataset: string -}) => { - const log: RpcLog = { unmocked: new Set() } - const calls = makeCallLog() - warmAssets(options.datasets.values()) - const rpcHandlers = new Map>() - const rpcFor = (dataset: Dataset) => { - let handler = rpcHandlers.get(dataset.name) - if (!handler) { - handler = makeRpcWebHandler(dataset, log) - rpcHandlers.set(dataset.name, handler) - } - return handler - } - - const resolveDataset = (request: Request) => { - const name = request.headers.get("x-parity-dataset") ?? options.defaultDataset - const dataset = options.datasets.get(name) - if (!dataset) throw new Error(`ui-parity: unknown dataset "${name}"`) - return dataset - } - - const electric = Bun.serve({ - port: options.electricPort, - idleTimeout: 0, - async fetch(request) { - if (request.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders(request) }) - if (request.method === "POST" && new URL(request.url).pathname === "/__parity/push") { - const change: PushedChange = await request.json() - const seq = pushChange(resolveDataset(request).name, change) - return Response.json({ seq }, { headers: corsHeaders(request) }) - } - return handleShape(request, resolveDataset(request)) - }, - }) - - const server = Bun.serve({ - port: options.port, - idleTimeout: 0, - websocket: makeRivetSocketHandlers(options.datasets), - async fetch(request, bunServer) { - const url = new URL(request.url) - if (request.method === "OPTIONS") - return new Response(null, { status: 204, headers: corsHeaders(request) }) - if (isAssetRequest(url)) return handleAsset(request, url) - const callLog = calls.handle(request, url) - if (callLog) return callLog - - const dataset = resolveDataset(request) - // Third-party hosts, not the Hazel backend: never part of the call log. - if (isNetworkRequest(url)) return handleNetwork(request, url, dataset) - if (isRivetRequest(url)) { - await calls.recordHttp(request, url) - return handleRivet(request, url, dataset, (data) => - bunServer.upgrade(request, { data, headers: { "sec-websocket-protocol": "rivet" } }), - ) - } - if (url.pathname.startsWith("/rpc")) { - await calls.recordRpc(request) - const rpcRequest = new Request(new URL("/rpc", url), request) - const response = await rpcFor(dataset).handler(rpcRequest) - const headers = new Headers(response.headers) - for (const [key, value] of Object.entries(corsHeaders(request))) headers.set(key, value) - return new Response(response.body, { status: response.status, headers }) - } - // Mostly not served (the HTTP API is outside the fixture), but still part of the behavior under test. - await calls.recordHttp(request, url) - const canned = dataset.http?.[`${request.method} ${url.pathname}`] - if (canned) return Response.json(canned(), { headers: corsHeaders(request) }) - return new Response("not found", { status: 404, headers: corsHeaders(request) }) - }, - }) - - return { - url: `http://localhost:${server.port}`, - log, - stop: async () => { - server.stop(true) - electric.stop(true) - await Promise.all([...rpcHandlers.values()].map((handler) => handler.dispose())) - }, - } -} diff --git a/packages/ui-parity/src/behavior.ts b/packages/ui-parity/src/behavior.ts deleted file mode 100644 index 32c197f01..000000000 --- a/packages/ui-parity/src/behavior.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { RecordedCall } from "./backend/call-log.ts" - -/** - * Behavioral parity (call logs) and accessibility parity (ARIA snapshots) between two captures. - * Both are compared as multisets: legacy and Foldkit fire concurrent queries in different orders, - * and ARIA trees nest through different wrappers, so only what was sent or exposed counts. - */ - -/** - * Background traffic whose count and timing depend on how long a capture ran, not on what the user - * did. Entries are prefixes of `: ` keys. - * - IGNORED_CALLS are never compared: presence heartbeats and typing-indicator pings. - * - PRESENCE_ONLY_CALLS only have to be sent at least once by both apps (or by neither): the presence - * status write on mount, and the Rivet actor client's metadata polling (unserved by the fixture). - */ -export const IGNORED_CALLS: ReadonlyArray = [ - "rpc: userPresenceStatus.heartbeat", - "rpc: typingIndicator.", -] -export const PRESENCE_ONLY_CALLS: ReadonlyArray = [ - "rpc: userPresenceStatus.update", - "http: GET /rivet/", -] - -const matches = (list: ReadonlyArray, key: string) => list.some((prefix) => key.startsWith(prefix)) - -// Fixture ids are version-5 (`stableId`); version-4 ids are minted by the app (`crypto.randomUUID`). -const RANDOM_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - -/** Sorted keys, app-minted ids replaced by a placeholder, so equal payloads serialize equally. */ -const normalize = (value: unknown): unknown => { - if (typeof value === "string") return RANDOM_UUID.test(value) ? "" : value - if (Array.isArray(value)) return value.map(normalize) - if (value && typeof value === "object") - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, normalize((value as Record)[key])]), - ) - return value -} - -/** First path where two normalized values differ: `.content`, `.attachmentIds[1]`, `` for the root. */ -const firstDifference = (a: unknown, b: unknown, path = ""): string | undefined => { - if (JSON.stringify(a) === JSON.stringify(b)) return undefined - if (Array.isArray(a) && Array.isArray(b)) { - for (let index = 0; index < Math.max(a.length, b.length); index++) { - const found = firstDifference(a[index], b[index], `${path}[${index}]`) - if (found !== undefined) return found - } - return path - } - if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { - const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort() - for (const key of keys) { - const found = firstDifference( - (a as Record)[key], - (b as Record)[key], - `${path}.${key}`, - ) - if (found !== undefined) return found - } - } - return path -} - -const SENT = "(sent)" - -const groupCalls = (calls: ReadonlyArray, ignoreBackground: boolean) => { - const groups = new Map() - for (const call of calls) { - const key = `${call.kind}: ${call.name}` - if (matches(IGNORED_CALLS, key)) continue - if (matches(PRESENCE_ONLY_CALLS, key)) { - if (!ignoreBackground) groups.set(key, [SENT]) - } else groups.set(key, [...(groups.get(key) ?? []), JSON.stringify(normalize(call.payload))]) - } - for (const payloads of groups.values()) payloads.sort() - return groups -} - -/** Removes the payloads both sides share, leaving what only each side sent. */ -const unmatched = (baseline: ReadonlyArray, candidate: ReadonlyArray) => { - const counts = new Map() - for (const item of candidate) counts.set(item, (counts.get(item) ?? 0) + 1) - const onlyBaseline: string[] = [] - for (const item of baseline) { - const left = counts.get(item) ?? 0 - if (left) counts.set(item, left - 1) - else onlyBaseline.push(item) - } - const onlyCandidate = candidate.filter((item) => { - const left = counts.get(item) ?? 0 - if (left) counts.set(item, left - 1) - return left > 0 - }) - return { onlyBaseline, onlyCandidate } -} - -/** - * One readable line per difference, e.g. `rpc: message.create payload differs at .content`. - * `ignoreBackground` drops PRESENCE_ONLY_CALLS too: Foldkit gallery pages boot a standalone - * program without the app root, so its background traffic legitimately differs there. - */ -export const diffCallLogs = ( - baseline: ReadonlyArray, - candidate: ReadonlyArray, - options: { readonly ignoreBackground?: boolean } = {}, -): string[] => { - const base = groupCalls(baseline, options.ignoreBackground ?? false) - const cand = groupCalls(candidate, options.ignoreBackground ?? false) - const lines: string[] = [] - for (const key of [...new Set([...base.keys(), ...cand.keys()])].sort()) { - const a = base.get(key) ?? [] - const b = cand.get(key) ?? [] - if (!b.length) - lines.push( - `${key} not sent (${a[0] === SENT ? "baseline sends it" : `${a.length}× in baseline`})`, - ) - else if (!a.length) - lines.push(`${key} sent${b[0] === SENT ? "" : ` ${b.length}×`}, never in baseline`) - else { - const { onlyBaseline, onlyCandidate } = unmatched(a, b) - if (a.length !== b.length) lines.push(`${key} sent ${b.length}×, ${a.length}× in baseline`) - for (let index = 0; index < Math.min(onlyBaseline.length, onlyCandidate.length); index++) { - const path = firstDifference( - JSON.parse(onlyBaseline[index]!), - JSON.parse(onlyCandidate[index]!), - ) - lines.push(`${key} payload differs at ${path || "(root)"}`) - } - } - } - return lines -} - -export interface AriaDelta { - /** ARIA snapshot lines (indentation stripped) present only in the baseline. */ - readonly missing: ReadonlyArray - /** Lines present only in the candidate. */ - readonly extra: ReadonlyArray -} - -const ariaLines = (snapshot: string) => - snapshot - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - -export const diffAria = (baseline: string, candidate: string): AriaDelta => { - const { onlyBaseline, onlyCandidate } = unmatched(ariaLines(baseline), ariaLines(candidate)) - return { missing: onlyBaseline, extra: onlyCandidate } -} - -/** The role an ARIA snapshot line describes (`- button "Send"` → `button`, `- /url: /x` → `/url`). */ -export const ariaRole = (line: string) => line.replace(/^- /, "").match(/^\/?[\w-]+/)?.[0] ?? line diff --git a/packages/ui-parity/src/bench/chat-list-probes.ts b/packages/ui-parity/src/bench/chat-list-probes.ts deleted file mode 100644 index d4bbbb2c9..000000000 --- a/packages/ui-parity/src/bench/chat-list-probes.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { Page } from "playwright" - -/** - * In-page probes for the chat list benchmark. Each function runs inside the page through - * `page.evaluate`, so it must be self-contained. Both apps render one `[data-id]` element per - * message, and the scroller is the nearest scrollable ancestor of a message. - */ - -export interface FrameStats { - readonly frameMs: ReadonlyArray - readonly blankFrames: number - readonly longTasks: number - readonly longTaskMs: number - readonly scrolledPx: number - readonly maxRowsInDom: number - /** scrollTop / scrollHeight before and after the run. */ - readonly geometry: readonly [number, number, number, number] -} - -/** Init script: records when the first message row enters the DOM (ms since navigation start). */ -export const installFirstRowProbe = () => { - const observer = new MutationObserver(() => { - if (document.querySelector("[data-id]")) { - ;(window as unknown as { __firstRowAt: number }).__firstRowAt = performance.now() - observer.disconnect() - } - }) - observer.observe(document, { childList: true, subtree: true }) -} - -/** Waits for the list to render and settle; returns the first-row time. */ -export const prepareList = async (page: Page): Promise => { - await page.waitForSelector("[data-id]", { timeout: 30_000 }) - return page.evaluate( - () => - new Promise((resolve) => { - let timer = setTimeout(done, 600) - const observer = new MutationObserver(() => { - clearTimeout(timer) - timer = setTimeout(done, 600) - }) - observer.observe(document.body, { childList: true, subtree: true, attributes: true }) - function done() { - observer.disconnect() - resolve((window as unknown as { __firstRowAt?: number }).__firstRowAt ?? Number.NaN) - } - }), - ) -} - -/** Scrolls `speed` px per frame for `seconds`, recording rAF intervals, blank frames and long tasks. */ -export const measureFrames = ( - page: Page, - options: { readonly direction: 1 | -1; readonly seconds: number; readonly speed: number }, -): Promise => - page.evaluate(async ({ direction, seconds, speed }) => { - const row = document.querySelector("[data-id]") - let scroller = row?.parentElement ?? null - while ( - scroller && - !( - scroller.scrollHeight > scroller.clientHeight + 1 && - /(auto|scroll)/.test(getComputedStyle(scroller).overflowY) - ) - ) - scroller = scroller.parentElement - if (!scroller) throw new Error("no scroller") - const rect = scroller.getBoundingClientRect() - const probe = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } - let longTasks = 0 - let longTaskMs = 0 - const observer = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - longTasks++ - longTaskMs += entry.duration - } - }) - observer.observe({ type: "longtask", buffered: false }) - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - const frameMs: number[] = [] - let blankFrames = 0 - let scrolledPx = 0 - let maxRowsInDom = 0 - const startGeometry = [scroller.scrollTop, scroller.scrollHeight] as const - let last = await nextFrame() - const end = last + seconds * 1000 - for (;;) { - const before = scroller.scrollTop - scroller.scrollTop = before + direction * speed - scrolledPx += Math.abs(scroller.scrollTop - before) - const now = await nextFrame() - frameMs.push(now - last) - last = now - const hit = document.elementFromPoint(probe.x, probe.y) - if (!hit?.closest("[data-id], [data-list-key], [data-index]")) blankFrames++ - maxRowsInDom = Math.max(maxRowsInDom, scroller.querySelectorAll("[data-id]").length) - if (now > end) break - } - observer.disconnect() - const geometry = [...startGeometry, scroller.scrollTop, scroller.scrollHeight].map(Math.round) as [ - number, - number, - number, - number, - ] - return { frameMs, blankFrames, longTasks, longTaskMs, scrolledPx, maxRowsInDom, geometry } - }, options) - -export interface PrependAnchorStats { - readonly anchorId: string - readonly prependedPx: number - readonly maxDeviationPx: number - readonly framesOver1px: number - readonly missingFrames: number - /** Per frame: scrollTop, scrollHeight, deviation (px). */ - readonly trace: ReadonlyArray -} - -/** - * Picks the message under a probe near the top, scrolls into the load-older threshold and holds - * still while the older page lands. Deviation = how far the anchor moved beyond our own scrolling. - */ -export const measurePrependAnchor = (page: Page): Promise => - page.evaluate(async () => { - const row = document.querySelector("[data-id]") - let scroller = row?.parentElement ?? null - while ( - scroller && - !( - scroller.scrollHeight > scroller.clientHeight + 1 && - /(auto|scroll)/.test(getComputedStyle(scroller).overflowY) - ) - ) - scroller = scroller.parentElement - if (!scroller) throw new Error("no scroller") - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - const viewport = scroller.clientHeight - const rect = scroller.getBoundingClientRect() - // Start one viewport from the top (outside the half-viewport threshold) and let rows render. - scroller.scrollTop = viewport - for (let frame = 0; frame < 15; frame++) await nextFrame() - const anchorElement = document - .elementFromPoint(rect.left + rect.width / 2, rect.top + 60) - ?.closest("[data-id]") - const anchorId = anchorElement?.getAttribute("data-id") - if (!anchorElement || !anchorId) throw new Error("no anchor row under the probe") - const relativeTop = () => { - const element = document.querySelector(`[data-id="${anchorId}"]`) - return element ? element.getBoundingClientRect().top - rect.top : undefined - } - const startTop = relativeTop()! - const startHeight = scroller.scrollHeight - let ownScroll = 0 - let maxDeviationPx = 0 - let framesOver1px = 0 - let missingFrames = 0 - const trace: Array = [] - const sample = () => { - const top = relativeTop() - if (top === undefined) { - missingFrames++ - return - } - const deviation = Math.abs(top - (startTop + ownScroll)) - trace.push([ - Math.round(scroller.scrollTop), - scroller.scrollHeight, - Math.round(top - (startTop + ownScroll)), - ]) - maxDeviationPx = Math.max(maxDeviationPx, deviation) - if (deviation > 1) framesOver1px++ - } - for (let step = 0; step < 6; step++) { - const before = scroller.scrollTop - scroller.scrollTop = before - 80 - ownScroll += before - scroller.scrollTop - await nextFrame() - sample() - } - for (let frame = 0; frame < 90; frame++) { - await nextFrame() - sample() - } - return { - anchorId, - prependedPx: Math.round(scroller.scrollHeight - startHeight), - maxDeviationPx: Math.round(maxDeviationPx * 10) / 10, - framesOver1px, - missingFrames, - trace, - } - }) - -export interface LiveUpdateStats { - readonly newMessageMs: ReadonlyArray - readonly stuckToBottom: number - readonly newMessageVisible: number - readonly reactionAddMs: ReadonlyArray - readonly reactionRemoveMs: ReadonlyArray -} - -/** - * Pushes messages (then a reaction toggle on each) through the fixture's live events while the - * reader sits at the bottom; times each until it shows in the DOM and checks the list followed it. - */ -export const measureLiveUpdates = ( - page: Page, - options: { readonly pushUrl: string; readonly channelId: string; readonly authorId: string; readonly nowMs: number; readonly count: number; readonly idBase: number }, -): Promise => - page.evaluate(async ({ pushUrl, channelId, authorId, nowMs, count, idBase }) => { - const row = document.querySelector("[data-id]") - let scroller = row?.parentElement ?? null - while ( - scroller && - !(scroller.scrollHeight > scroller.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) - ) - scroller = scroller.parentElement - if (!scroller) throw new Error("no scroller") - const list = scroller - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - const until = async (condition: () => boolean) => { - const started = performance.now() - while (!condition()) { - if (performance.now() - started > 5000) return Number.NaN - await nextFrame() - } - return performance.now() - } - const push = (body: unknown) => fetch(pushUrl, { method: "POST", body: JSON.stringify(body) }) - const uuid = (prefix: string, index: number) => `${prefix}-0000-4000-8000-${String(idBase + index).padStart(12, "0")}` - list.scrollTop = list.scrollHeight - for (let frame = 0; frame < 20; frame++) await nextFrame() - - const newMessageMs: number[] = [] - const reactionAddMs: number[] = [] - const reactionRemoveMs: number[] = [] - let stuckToBottom = 0 - let newMessageVisible = 0 - for (let index = 1; index <= count; index++) { - const id = uuid("eeeeeeee", index) - const began = performance.now() - await push({ - table: "messages", - operation: "insert", - row: { - id, - channelId, - conversationId: null, - authorId, - content: `Live message ${index} arriving at the bottom`, - embeds: null, - replyToMessageId: null, - threadChannelId: null, - createdAt: new Date(nowMs + index * 1000).toISOString(), - updatedAt: null, - deletedAt: null, - }, - }) - newMessageMs.push((await until(() => document.querySelector(`[data-id="${id}"]`) !== null)) - began) - for (let frame = 0; frame < 15; frame++) await nextFrame() - if (list.scrollHeight - list.clientHeight - list.scrollTop <= 2) stuckToBottom++ - const element = document.querySelector(`[data-id="${id}"]`) - if (element && element.getBoundingClientRect().bottom <= list.getBoundingClientRect().bottom + 1) newMessageVisible++ - - const reactionId = uuid("dddddddd", index) - const reaction = { id: reactionId, messageId: id, channelId, conversationId: null, userId: authorId, emoji: "🚀", createdAt: new Date(nowMs).toISOString() } - const hasRocket = () => (document.querySelector(`[data-id="${id}"]`)?.textContent ?? "").includes("🚀") - const addBegan = performance.now() - await push({ table: "message_reactions", operation: "insert", row: reaction }) - reactionAddMs.push((await until(hasRocket)) - addBegan) - const removeBegan = performance.now() - await push({ table: "message_reactions", operation: "delete", row: reaction }) - reactionRemoveMs.push((await until(() => !hasRocket())) - removeBegan) - } - return { newMessageMs, stuckToBottom, newMessageVisible, reactionAddMs, reactionRemoveMs } - }, options) diff --git a/packages/ui-parity/src/bench/chat-list.ts b/packages/ui-parity/src/bench/chat-list.ts deleted file mode 100644 index 1d990632a..000000000 --- a/packages/ui-parity/src/bench/chat-list.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { existsSync } from "node:fs" -import { join } from "node:path" -import { parseArgs } from "node:util" -import { chromium, type Page } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { - buildDir, - FIXTURE_BACKEND_PORT, - FIXTURE_ELECTRIC_PORT, - fixtureElectricUrl, - targets, - type TargetName, -} from "../config.ts" -import { heavyIds } from "../fixtures/datasets/heavy.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" -import { - installFirstRowProbe, - measureFrames, - measureLiveUpdates, - measurePrependAnchor, - prepareList, - type FrameStats, -} from "./chat-list-probes.ts" - -/** - * S4 benchmark: scroll the heavy dataset's 10k-message channel in the static parity builds and - * report frame times (rAF probe), blank frames and long tasks, plus how far the anchored row - * moves when an older page is prepended. No dev server: the builds and the fixture backend. - * - * PARITY_PORT_BASE=5750 bun run src/bench/chat-list.ts [--targets legacy,foldkit] [--seconds 6] [--speed 40] [--runs 3] - */ - -const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - targets: { type: "string", default: "legacy,foldkit" }, - seconds: { type: "string", default: "6" }, - speed: { type: "string", default: "40" }, - runs: { type: "string", default: "3" }, - headed: { type: "boolean", default: false }, - trace: { type: "boolean", default: false }, - "anchor-only": { type: "boolean", default: false }, - live: { type: "boolean", default: false }, - }, -}) - -const names = values.targets.split(",") as TargetName[] -const dataset = datasets.get("heavy")! -const path = `/${heavyIds.orgSlug}/chat/${heavyIds.bigChannelId}` - -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: "heavy", -}) -const servers = names.map((name) => { - const dir = buildDir(name) - if (!existsSync(join(dir, "index.html"))) - throw new Error(`No build for "${name}". Run: bun parity build ${name}`) - return serveStatic(dir, targets[name].port, clerkIdentityFor(dataset)) -}) -const browser = await chromium.launch({ ...BROWSER_LAUNCH_OPTIONS, headless: !values.headed }) - -/** JS heap in MB after a forced GC, through the DevTools protocol. */ -const heapMb = async (page: Page) => { - const cdp = await page.context().newCDPSession(page) - await cdp.send("HeapProfiler.collectGarbage") - const { usedSize } = await cdp.send("Runtime.getHeapUsage") - await cdp.detach() - return Math.round(usedSize / 1e5) / 10 -} - -const openChannel = async (name: TargetName): Promise<{ page: Page; firstRowMs: number }> => { - const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }) - const page = await context.newPage() - page.setDefaultTimeout(30_000) - page.on("pageerror", (error) => console.log(` [${name}] pageerror: ${error.message.slice(0, 200)}`)) - await page.addInitScript((identity) => { - ;(window as unknown as { __parityClerkIdentity: unknown }).__parityClerkIdentity = identity - }, clerkIdentityFor(dataset)) - await page.addInitScript(installFirstRowProbe) - await page.goto(`http://localhost:${targets[name].port}${path}`, { waitUntil: "load" }) - const firstRowMs = await prepareList(page) - return { page, firstRowMs } -} - -const percentile = (values: ReadonlyArray, p: number) => { - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))] ?? 0 -} - -const summarize = (label: string, stats: FrameStats) => { - const over = (ms: number) => stats.frameMs.filter((frame) => frame > ms).length - return { - label, - frames: stats.frameMs.length, - p50: +percentile(stats.frameMs, 50).toFixed(1), - p95: +percentile(stats.frameMs, 95).toFixed(1), - p99: +percentile(stats.frameMs, 99).toFixed(1), - max: +Math.max(...stats.frameMs).toFixed(1), - "over20ms%": +((100 * over(20)) / stats.frameMs.length).toFixed(1), - "blank%": +((100 * stats.blankFrames) / stats.frameMs.length).toFixed(1), - longTasks: stats.longTasks, - longTaskMs: Math.round(stats.longTaskMs), - scrolledPx: Math.round(stats.scrolledPx), - rowsInDom: stats.maxRowsInDom, - geometry: stats.geometry.join("/"), - } -} - -const results: Array> = [] -for (const name of names) { - for (let run = 1; run <= Number(values.runs); run++) { - if (values.live) { - const live = await openChannel(name) - const stats = await measureLiveUpdates(live.page, { - pushUrl: `${new URL(fixtureElectricUrl).origin}/__parity/push`, - channelId: heavyIds.bigChannelId, - authorId: heavyIds.currentUserId, - nowMs: dataset.now.getTime() + run * 100_000, - count: 10, - idBase: run * 1000 + targets[name].port, - }) - results.push({ - target: name, - run, - label: "live events", - newMessageP50: percentile(stats.newMessageMs, 50), - newMessageP95: percentile(stats.newMessageMs, 95), - stuckToBottom: `${stats.stuckToBottom}/10`, - newMessageVisible: `${stats.newMessageVisible}/10`, - reactionAddP50: percentile(stats.reactionAddMs, 50), - reactionRemoveP50: percentile(stats.reactionRemoveMs, 50), - }) - await live.page.context().close() - continue - } - - if (!values["anchor-only"]) { - const { page, firstRowMs } = await openChannel(name) - const heapAfterLoadMb = await heapMb(page) - const up = await measureFrames(page, { - direction: -1, - seconds: Number(values.seconds), - speed: Number(values.speed), - }) - const down = await measureFrames(page, { - direction: 1, - seconds: Number(values.seconds), - speed: Number(values.speed), - }) - results.push({ - target: name, - run, - firstRowMs: Math.round(firstRowMs), - heapMb: heapAfterLoadMb, - ...summarize("up (paging)", up), - }) - results.push({ - target: name, - run, - heapMb: await heapMb(page), - ...summarize("down (loaded)", down), - }) - await page.context().close() - } - - const fresh = await openChannel(name) - const { trace, ...anchor } = await measurePrependAnchor(fresh.page) - results.push({ target: name, run, label: "prepend anchor", ...anchor }) - if (values.trace) console.log(name, JSON.stringify(trace)) - await fresh.page.context().close() - } -} - -console.table(results) -console.log(JSON.stringify(results)) -await browser.close() -for (const server of servers) server.stop(true) -await backend.stop() diff --git a/packages/ui-parity/src/bench/perf-probes.ts b/packages/ui-parity/src/bench/perf-probes.ts deleted file mode 100644 index d71139f47..000000000 --- a/packages/ui-parity/src/bench/perf-probes.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * In-page probes for the perf suite (`perf.ts`). `installPerfProbes` runs as an init script and - * exposes `window.__perf`; everything inside must be self-contained. Times are `performance.now()`. - */ - -export interface FrameRun { - readonly frameMs: ReadonlyArray - readonly blankFrames: number - readonly longTasks: number - readonly longTaskMs: number - readonly scrolledPx: number -} - -export interface ReadyResult { - /** Input event timestamp to the first frame where the condition held, plus a post-paint task. */ - readonly ms: number - /** - * Like `ms`, but also checked after each frame's paint, so a DOM patch made in an rAF callback - * that runs after this probe's own (a rAF-batched renderer) counts in the frame that painted it. - */ - readonly commitMs: number - readonly frames: number - /** Frames between input and ready where no message row was in the DOM (blank or spinner). */ - readonly blankFrames: number - readonly timedOut: boolean -} - -/** What "ready" means: a visible element matching `selector`, a visible row from `ids`, or `text`. */ -export interface ReadySpec { - readonly selector?: string - readonly ids?: ReadonlyArray - readonly text?: string - /** For typing: the focused editor's text length reaches this. */ - readonly editorLength?: number - /** With `selector`: at least this many visible matches (an overlay duplicating an element). */ - readonly minCount?: number -} - -export interface LoadMarks { - readonly fcp: number - readonly firstRow: number - readonly tti: number - readonly longTasks: number -} - -/** Builds the probe API; injected as `window.__perf = (createPerfProbes)()` before any app code. */ -export const createPerfProbes = () => { - const longTasks: Array = [] - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) longTasks.push([entry.startTime, entry.duration]) - }).observe({ type: "longtask", buffered: true }) - let lastInputAt = 0 - for (const type of ["pointerdown", "keydown"]) - window.addEventListener(type, (event) => (lastInputAt = event.timeStamp), { capture: true }) - let firstRowAt = Number.NaN - const rowObserver = new MutationObserver(() => { - if (document.querySelector("[data-id]")) { - firstRowAt = performance.now() - rowObserver.disconnect() - } - }) - rowObserver.observe(document, { childList: true, subtree: true }) - - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - const afterPaint = () => - new Promise((resolve) => { - const channel = new MessageChannel() - channel.port1.onmessage = () => resolve(performance.now()) - channel.port2.postMessage(null) - }) - const visible = (element: Element | null) => { - if (!element) return false - const rect = element.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < innerHeight - } - const ready = (spec: { - selector?: string - ids?: ReadonlyArray - text?: string - editorLength?: number - minCount?: number - }) => { - if ( - spec.selector && - Array.from(document.querySelectorAll(spec.selector)).filter(visible).length < (spec.minCount ?? 1) - ) - return false - if (spec.ids) { - const wanted = new Set(spec.ids) - const rows = Array.from(document.querySelectorAll("[data-id]")) - if (!rows.some((row) => wanted.has(row.getAttribute("data-id") ?? "") && visible(row))) - return false - } - if (spec.text) { - const literal = JSON.stringify(spec.text).replace(/^"|"$/g, "") - const hit = document.evaluate( - `//*[contains(text(), "${literal}")]`, - document.body, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue - if (!(hit instanceof Element) || !visible(hit)) return false - } - if (spec.editorLength !== undefined) { - const active = document.activeElement - const length = - active instanceof HTMLTextAreaElement - ? active.value.length - : (active?.textContent ?? "").length - if (length < spec.editorLength) return false - } - return true - } - const scrollerOf = (start: Element | null) => { - let element = start?.parentElement ?? null - while ( - element && - !( - element.scrollHeight > element.clientHeight + 1 && - /(auto|scroll)/.test(getComputedStyle(element).overflowY) - ) - ) - element = element.parentElement - return element - } - - let recording: { stop: () => void; result: Promise } | undefined - const perf = { - lastInputAt: () => lastInputAt, - scrollerRect: (selector: string) => { - const scroller = scrollerOf(document.querySelector(selector)) - if (!scroller) return undefined - const rect = scroller.getBoundingClientRect() - return { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - top: scroller.scrollTop, - height: scroller.scrollHeight, - client: scroller.clientHeight, - } - }, - scrollTo: (selector: string, where: "top" | "bottom" | number) => { - const scroller = scrollerOf(document.querySelector(selector)) - if (!scroller) throw new Error(`no scroller for ${selector}`) - scroller.scrollTop = where === "top" ? 0 : where === "bottom" ? scroller.scrollHeight : where - }, - /** Records rAF intervals until `stopFrames`; a frame is blank when no row is under the probe. */ - startFrames: (options: { probeSelector: string; rowSelector: string }) => { - const scroller = scrollerOf(document.querySelector(options.probeSelector)) - const rect = scroller?.getBoundingClientRect() - const probe = rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : undefined - const startLongTasks = longTasks.length - let stopped = false - const result = (async (): Promise => { - const frameMs: number[] = [] - let blankFrames = 0 - let scrolledPx = 0 - let lastTop = scroller?.scrollTop ?? 0 - let last = await nextFrame() - while (!stopped) { - const now = await nextFrame() - frameMs.push(now - last) - last = now - if (scroller) { - scrolledPx += Math.abs(scroller.scrollTop - lastTop) - lastTop = scroller.scrollTop - } - if (probe && !document.elementFromPoint(probe.x, probe.y)?.closest(options.rowSelector)) - blankFrames++ - } - const tasks = longTasks.slice(startLongTasks) - return { - frameMs, - blankFrames, - longTasks: tasks.length, - longTaskMs: tasks.reduce((sum, [, duration]) => sum + duration, 0), - scrolledPx, - } - })() - recording = { stop: () => (stopped = true), result } - }, - stopFrames: async () => { - const current = recording - if (!current) throw new Error("not recording") - current.stop() - recording = undefined - return current.result - }, - /** Armed before the input; resolves once `spec` holds, measured from the input's timestamp. */ - waitReady: async (spec: Parameters[0], timeoutMs: number) => { - const armedAt = performance.now() - let frames = 0 - let blankFrames = 0 - let committedAt = Number.NaN - let committedFrame = -1 - let rafs = 0 - while (!ready(spec) || (!Number.isNaN(committedAt) && rafs === committedFrame)) { - if (performance.now() - armedAt > timeoutMs) - return { ms: Number.NaN, commitMs: Number.NaN, frames, blankFrames, timedOut: true } - await nextFrame() - rafs++ - if (lastInputAt > armedAt) { - frames++ - if (!document.querySelector("[data-id]")) blankFrames++ - } - // `commitMs`: a patch made in a later rAF callback of this frame is caught after its paint; - // `ms` keeps waiting for the next frame as before. - if (Number.isNaN(committedAt) && !ready(spec)) { - const postFrameAt = await afterPaint() - if (ready(spec)) [committedAt, committedFrame] = [postFrameAt, rafs] - } - } - const paintedAt = await afterPaint() - const commitMs = (Number.isNaN(committedAt) ? paintedAt : committedAt) - lastInputAt - return { ms: paintedAt - lastInputAt, commitMs, frames, blankFrames, timedOut: false } - }, - /** FCP, first message row, and a TTI estimate: end of the last long task before a 2 s quiet window. */ - loadMarks: async () => { - const fcpEntry = performance.getEntriesByName("first-contentful-paint")[0] - const fcp = fcpEntry?.startTime ?? Number.NaN - for (;;) { - const lastEnd = longTasks.reduce( - (end, [start, duration]) => Math.max(end, start + duration), - 0, - ) - const from = Math.max(lastEnd, firstRowAt, fcp) - if (performance.now() - from >= 2000) - return { fcp, firstRow: firstRowAt, tti: from, longTasks: longTasks.length } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - }, - } - return perf -} - -export type PerfProbes = ReturnType - -declare global { - interface Window { - __perf: PerfProbes - } -} - -export const perfProbesInitScript = `window.__perf = (${createPerfProbes.toString()})()` diff --git a/packages/ui-parity/src/bench/perf-trace.ts b/packages/ui-parity/src/bench/perf-trace.ts deleted file mode 100644 index fa7067aa3..000000000 --- a/packages/ui-parity/src/bench/perf-trace.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Splits a DevTools timeline trace's renderer main-thread time into script, style, layout, paint, - * GC and other, by self time (nested events are subtracted from their parents). - */ - -export interface TraceEvent { - readonly ph: string - readonly name: string - readonly pid: number - readonly tid: number - readonly ts: number - readonly dur?: number - readonly args?: { readonly name?: string } -} - -export const TRACE_CATEGORIES = [ - "toplevel", - "devtools.timeline", - "disabled-by-default-devtools.timeline", - "v8.execute", - "blink", -] - -type Category = "script" | "style" | "layout" | "paint" | "gc" | "other" - -const categoryOf = (name: string): Category => { - if ( - /^(FunctionCall|EvaluateScript|TimerFire|EventDispatch|RunMicrotasks|FireAnimationFrame|v8\.|V8\.|CompileScript|ParseHTML|XHR|FireIdleCallback)/.test( - name, - ) - ) - return "script" - if (/^(UpdateLayoutTree|RecalculateStyles|ParseAuthorStyleSheet)/.test(name)) return "style" - if (/^(Layout|UpdateLayout)$/.test(name)) return "layout" - if ( - /^(Paint|PrePaint|Layerize|UpdateLayer|UpdateLayerTree|CompositeLayers|Commit|PaintImage|RasterTask|Decode)/.test( - name, - ) - ) - return "paint" - if (/GC|GarbageCollect/.test(name)) return "gc" - return "other" -} - -export type TraceSplit = Record<"busy" | "script" | "style" | "layout" | "paint" | "gc" | "other", number> - -/** Milliseconds per category on every CrRendererMain thread in the trace. */ -export const splitTrace = (events: ReadonlyArray): TraceSplit => { - const mainThreads = new Set( - events - .filter( - (event) => - event.ph === "M" && event.name === "thread_name" && event.args?.name === "CrRendererMain", - ) - .map((event) => `${event.pid}:${event.tid}`), - ) - const split: TraceSplit = { busy: 0, script: 0, style: 0, layout: 0, paint: 0, gc: 0, other: 0 } - const byThread = new Map() - for (const event of events) { - const key = `${event.pid}:${event.tid}` - if (event.ph !== "X" || !event.dur || !mainThreads.has(key)) continue - const list = byThread.get(key) ?? [] - list.push(event) - byThread.set(key, list) - } - for (const list of byThread.values()) { - list.sort((a, b) => a.ts - b.ts || (b.dur ?? 0) - (a.dur ?? 0)) - const stack: Array<{ end: number; category: Category; self: number }> = [] - const close = (until: number) => { - while (stack.length && stack[stack.length - 1]!.end <= until) { - const done = stack.pop()! - split[done.category] += done.self / 1000 - } - } - for (const event of list) { - const dur = event.dur ?? 0 - close(event.ts) - const parent = stack[stack.length - 1] - if (parent) parent.self -= Math.min(dur, parent.end - event.ts) - else split.busy += dur / 1000 - stack.push({ end: event.ts + dur, category: categoryOf(event.name), self: dur }) - } - close(Number.POSITIVE_INFINITY) - } - return split -} - -/** The renderer main thread that ran our rAF probe (other renderers in the trace are ignored). */ -const pageMainThread = (events: ReadonlyArray) => { - const mains = events - .filter( - (event) => - event.ph === "M" && event.name === "thread_name" && event.args?.name === "CrRendererMain", - ) - .map((event) => `${event.pid}:${event.tid}`) - const rafCount = new Map() - for (const event of events) - if (event.name === "FireAnimationFrame") { - const key = `${event.pid}:${event.tid}` - rafCount.set(key, (rafCount.get(key) ?? 0) + 1) - } - return mains.sort((a, b) => (rafCount.get(b) ?? 0) - (rafCount.get(a) ?? 0))[0] -} - -/** - * Main-thread work per frame: top-level task time between consecutive animation frames. This is the - * number that has to fit in 8.33 ms for 120 fps, independent of the 60 Hz headless vsync. - */ -export const frameCosts = (events: ReadonlyArray): ReadonlyArray => { - const main = pageMainThread(events) - if (main === undefined) return [] - const onMain = events.filter( - (event) => `${event.pid}:${event.tid}` === main && event.ph === "X" && event.dur, - ) - const starts = onMain - .filter((event) => event.name === "FireAnimationFrame") - .map((event) => event.ts) - .sort((a, b) => a - b) - const topLevel: Array<[number, number]> = [] - let coveredUntil = 0 - for (const event of [...onMain].sort((a, b) => a.ts - b.ts)) { - const end = event.ts + (event.dur ?? 0) - if (event.ts >= coveredUntil) topLevel.push([event.ts, end]) - else if (end > coveredUntil) topLevel[topLevel.length - 1]![1] = end - coveredUntil = Math.max(coveredUntil, end) - } - const costs: number[] = [] - let cursor = 0 - for (let frame = 0; frame + 1 < starts.length; frame++) { - const [from, to] = [starts[frame]!, starts[frame + 1]!] - while (cursor < topLevel.length && topLevel[cursor]![1] <= from) cursor++ - let busy = 0 - for (let i = cursor; i < topLevel.length && topLevel[i]![0] < to; i++) - busy += Math.min(to, topLevel[i]![1]) - Math.max(from, topLevel[i]![0]) - costs.push(busy / 1000) - } - return costs -} - -/** `splitTrace` restricted to the page's main thread. */ -export const splitPageTrace = (events: ReadonlyArray): TraceSplit => { - const main = pageMainThread(events) - return splitTrace( - events.filter( - (event) => - `${event.pid}:${event.tid}` === main && - (event.ph === "X" || (event.ph === "M" && event.name === "thread_name")), - ), - ) -} diff --git a/packages/ui-parity/src/bench/perf.ts b/packages/ui-parity/src/bench/perf.ts deleted file mode 100644 index b1961f454..000000000 --- a/packages/ui-parity/src/bench/perf.ts +++ /dev/null @@ -1,673 +0,0 @@ -import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { parseArgs } from "node:util" -import { gzipSync } from "bun" -import { chromium, type Browser, type Page } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { - buildDir, - FIXTURE_BACKEND_PORT, - FIXTURE_ELECTRIC_PORT, - fixtureBackendUrl, - fixtureElectricUrl, - outDir, - targets, - type TargetName, -} from "../config.ts" -import type { Dataset } from "../fixtures/dataset.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" -import { perfProbesInitScript, type FrameRun, type ReadySpec } from "./perf-probes.ts" -import { - frameCosts, - splitPageTrace, - TRACE_CATEGORIES, - type TraceEvent, - type TraceSplit, -} from "./perf-trace.ts" - -/** - * Perf suite: legacy vs foldkit, N runs each, in Chromium with the frame rate uncapped, so a rAF - * interval is the real cost of a frame (120 fps = 8.33 ms). Writes .parity/perf//report.{json,md}. - * - * PARITY_PORT_BASE=8300 bun run src/bench/perf.ts [--runs 5] [--only scroll,switch,...] [--name baseline] - * - * Groups: load, scroll, switch, interact, sidebar, memory, bundle. - */ - -const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - targets: { type: "string", default: "legacy,foldkit" }, - runs: { type: "string", default: "5" }, - only: { type: "string", default: "load,scroll,switch,interact,sidebar,memory,bundle" }, - name: { type: "string", default: new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-") }, - seconds: { type: "string", default: "2" }, - "no-trace": { type: "boolean", default: false }, - headed: { type: "boolean", default: false }, - verbose: { type: "boolean", default: false }, - }, -}) -const names = values.targets.split(",").filter((name): name is TargetName => name in targets) -const runs = Number(values.runs) -const groups = new Set(values.only.split(",")) -const seconds = Number(values.seconds) - -const heavy = datasets.get("heavy")! -const rich = datasets.get("rich")! - -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: "heavy", -}) -const servers = names.map((name) => serveStatic(buildDir(name), targets[name].port, clerkIdentityFor(heavy))) -const browser: Browser = await chromium.launch({ - ...BROWSER_LAUNCH_OPTIONS, - headless: !values.headed, - args: [...BROWSER_LAUNCH_OPTIONS.args, "--disable-frame-rate-limit", "--disable-gpu-vsync"], -}) - -// ---------- samples ---------- - -interface LatencySample { - readonly kind: "latency" - readonly value: number - readonly blankFrames?: number -} -interface FrameSample { - readonly kind: "frames" - readonly run: FrameRun - /** Main-thread ms per frame from the trace; empty with --no-trace. */ - readonly costMs: ReadonlyArray - readonly split?: TraceSplit -} -const latencies = new Map>() -const frames = new Map>() -const errors: string[] = [] - -const bucket =
(map: Map>, metric: string, target: TargetName) => { - const byTarget = map.get(metric) ?? new Map() - map.set(metric, byTarget) - const list = byTarget.get(target) ?? [] - byTarget.set(target, list) - return list -} -const recordLatency = (metric: string, target: TargetName, value: number, blankFrames?: number) => - bucket(latencies, metric, target).push({ kind: "latency", value, blankFrames }) -const recordFrames = ( - metric: string, - target: TargetName, - sample: { run: FrameRun; costMs: ReadonlyArray; split?: TraceSplit }, -) => bucket(frames, metric, target).push({ kind: "frames", ...sample }) - -/** Runs a step; a failure is logged and recorded instead of aborting the suite. */ -const attempt = (label: string, step: () => Promise) => - step().then( - () => undefined, - (error: unknown) => { - const message = `${label}: ${String(error).split("\n")[0]?.slice(0, 200)}` - errors.push(message) - console.log(` ! ${message}`) - }, - ) - -// ---------- pages ---------- - -const backendOrigins = [fixtureBackendUrl, new URL(fixtureElectricUrl).origin] - -const openPage = async (target: TargetName, dataset: Dataset, path: string) => { - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, - serviceWorkers: "block", - timezoneId: "UTC", - locale: "en-US", - }) - const page = await context.newPage() - page.setDefaultTimeout(15_000) - page.on("console", (message) => { - if (values.verbose && message.type() === "error") - console.log(` [console] ${message.text().slice(0, 1500)}`) - }) - page.on("pageerror", (error) => { - errors.push(`${target} ${path} pageerror: ${error.message.slice(0, 200)}`) - if (values.verbose) console.log(error.stack) - }) - await page.addInitScript((identity) => { - ;(window as unknown as { __parityClerkIdentity: unknown }).__parityClerkIdentity = identity - }, clerkIdentityFor(dataset)) - await page.addInitScript(perfProbesInitScript) - for (const origin of backendOrigins) - await page.route(`${origin}/**`, (route) => - route.continue({ headers: { ...route.request().headers(), "x-parity-dataset": dataset.name } }), - ) - await page.goto(`http://localhost:${targets[target].port}${path}`, { waitUntil: "load" }) - return page -} - -/** Waits until the DOM has been quiet for `quietMs`. */ -const settle = (page: Page, quietMs = 500) => - page.evaluate( - (quiet) => - new Promise((resolve) => { - let timer = setTimeout(done, quiet) - const observer = new MutationObserver(() => { - clearTimeout(timer) - timer = setTimeout(done, quiet) - }) - observer.observe(document.body, { childList: true, subtree: true, attributes: true }) - function done() { - observer.disconnect() - resolve() - } - }), - quietMs, - ) - -const openChat = async (target: TargetName, dataset: Dataset, channelId: string) => { - const page = await openPage(target, dataset, `/hazel/chat/${channelId}`) - await page.waitForSelector("[data-id]", { timeout: 30_000 }) - await settle(page) - return page -} - -/** Arms the in-page ready probe, performs the input, and returns the input-to-paint time. */ -const timeInput = async (page: Page, spec: ReadySpec, input: () => Promise, timeoutMs = 10_000) => { - const ready = page.evaluate(({ spec, timeoutMs }) => window.__perf.waitReady(spec, timeoutMs), { - spec, - timeoutMs, - }) - await input() - const result = await ready - if (result.timedOut && values.verbose) await page.screenshot({ path: "/tmp/perf-scripts/timeout.png" }) - if (result.timedOut) throw new Error(`timed out waiting for ${JSON.stringify(spec).slice(0, 120)}`) - return result -} - -const heapMb = async (page: Page) => { - const cdp = await page.context().newCDPSession(page) - await cdp.send("HeapProfiler.collectGarbage") - const { usedSize } = await cdp.send("Runtime.getHeapUsage") - await cdp.detach() - return Math.round(usedSize / 1e5) / 10 -} - -// ---------- dataset helpers ---------- - -const messagesOf = (dataset: Dataset, channelId: string) => - (dataset.tables.messages ?? []).filter((row) => row.channelId === channelId).map((row) => String(row.id)) - -/** Channels the signed-in user belongs to with the most messages (threads excluded). */ -const busiestChannels = (dataset: Dataset, count: number, exclude: ReadonlyArray = []) => { - const userId = dataset.currentUser.id - const member = new Set( - (dataset.tables.channel_members ?? []) - .filter((row) => row.userId === userId) - .map((row) => String(row.channelId)), - ) - const channels = (dataset.tables.channels ?? []).filter( - (row) => member.has(String(row.id)) && row.type !== "thread" && !exclude.includes(String(row.id)), - ) - return channels - .map((row) => ({ id: String(row.id), count: messagesOf(dataset, String(row.id)).length })) - .filter((channel) => channel.count >= 4) - .sort((a, b) => b.count - a.count) - .slice(0, count) - .map((channel) => channel.id) -} - -const channelLink = (page: Page, channelId: string) => page.locator(`a[href$="/chat/${channelId}"]`).first() - -const heavyBig = heavy.tables.channels!.find((row) => row.name === "firehose")!.id as string -const heavyGeneral = heavy.tables.channels!.find((row) => row.name === "general")!.id as string - -// ---------- groups ---------- - -const measureLoad = async (target: TargetName) => { - for (const [workspace, dataset, channelId] of [ - ["heavy", heavy, heavyBig], - ["normal", rich, busiestChannels(rich, 1)[0]!], - ] as const) { - await attempt(`load ${workspace}`, async () => { - const page = await openPage(target, dataset, `/hazel/chat/${channelId}`) - await page.waitForSelector("[data-id]", { timeout: 30_000 }) - const marks = await page.evaluate(() => window.__perf.loadMarks()) - recordLatency(`load ${workspace}: first contentful paint`, target, marks.fcp) - recordLatency(`load ${workspace}: first message row`, target, marks.firstRow) - recordLatency(`load ${workspace}: interactive (TTI estimate)`, target, marks.tti) - const scriptKb = await page.evaluate( - () => - performance - .getEntriesByType("resource") - .filter((entry) => entry.name.endsWith(".js")) - .reduce( - (sum, entry) => - sum + - (entry instanceof PerformanceResourceTiming ? entry.decodedBodySize : 0), - 0, - ) / 1024, - ) - recordLatency(`load ${workspace}: JS loaded (KB, decoded)`, target, Math.round(scriptKb)) - await page.context().close() - }) - } -} - -/** Wheels over the scroller from Node (real input) while the page records frames. */ -const wheelFrames = async ( - page: Page, - probeSelector: string, - rowSelector: string, - delta: number, - durationMs: number, -) => { - const rect = await page.evaluate((selector) => window.__perf.scrollerRect(selector), probeSelector) - if (!rect) - throw new Error( - `no scroller under ${probeSelector}: ${await page.evaluate((selector) => { - const chain: string[] = [] - for (let e = document.querySelector(selector); e && chain.length < 9; e = e.parentElement) - chain.push( - `${e.tagName} ${e.scrollHeight}/${e.clientHeight} ${getComputedStyle(e).overflowY}`, - ) - return chain.join(" < ") - }, probeSelector)}`, - ) - await page.mouse.move(rect.x, rect.y) - await page.evaluate((options) => window.__perf.startFrames(options), { probeSelector, rowSelector }) - const end = Date.now() + durationMs - while (Date.now() < end) await page.mouse.wheel(0, delta) - return page.evaluate(() => window.__perf.stopFrames()) -} - -/** Runs a frame measurement under a timeline trace (unless --no-trace) for per-frame main-thread cost. */ -const traced = async (page: Page, body: () => Promise) => { - if (values["no-trace"]) return { run: await body(), costMs: [] } - await browser.startTracing(page, { categories: TRACE_CATEGORIES }) - const run = await body() - const buffer = await browser.stopTracing() - if (values.verbose) writeFileSync("/tmp/perf-scripts/last-trace.json", buffer) - const trace: { traceEvents: TraceEvent[] } = JSON.parse(buffer.toString()) - return { run, costMs: frameCosts(trace.traceEvents), split: splitPageTrace(trace.traceEvents) } -} - -const SPEEDS = [ - ["slow wheel (60px)", 60], - ["medium wheel (200px)", 200], - ["fast wheel (600px)", 600], -] as const - -const measureScroll = async (target: TargetName) => { - for (const [label, channelId] of [ - ["heavy #firehose (10k)", heavyBig], - ["normal #general (60)", heavyGeneral], - ] as const) { - await attempt(`scroll ${label}`, async () => { - const page = await openChat(target, heavy, channelId) - const run = async (metric: string, body: () => Promise) => - recordFrames(`scroll ${label}: ${metric}`, target, await traced(page, body)) - for (const [speed, delta] of SPEEDS) - await run(speed, () => wheelFrames(page, "[data-id]", "[data-id]", -delta, seconds * 1000)) - await run("fling to top (history prepend)", () => - wheelFrames(page, "[data-id]", "[data-id]", -4000, seconds * 1000), - ) - await run("jump to bottom", async () => { - await page.evaluate(() => - window.__perf.startFrames({ probeSelector: "[data-id]", rowSelector: "[data-id]" }), - ) - const started = Date.now() - await page.evaluate(() => window.__perf.scrollTo("[data-id]", "bottom")) - await settle(page, 300) - recordLatency( - `scroll ${label}: jump to bottom settle (ms)`, - target, - Date.now() - started - 300, - ) - return page.evaluate(() => window.__perf.stopFrames()) - }) - await page.context().close() - }) - } -} - -const measureSwitch = async (target: TargetName) => { - for (const [workspace, dataset, extra] of [ - ["heavy", heavy, [heavyBig]], - ["normal", rich, []], - ] as const) { - await attempt(`switch ${workspace}`, async () => { - const channels = busiestChannels(dataset, 4, [heavyBig]) - const page = await openChat(target, dataset, channels[0]!) - const visit = async (temperature: "cold" | "warm", channelId: string, suffix = "") => { - const result = await timeInput(page, { ids: messagesOf(dataset, channelId) }, () => - channelLink(page, channelId).click(), - ) - recordLatency( - `switch ${workspace}${suffix}: ${temperature}`, - target, - result.ms, - result.blankFrames, - ) - await settle(page, 300) - } - for (const channelId of channels.slice(1)) await visit("cold", channelId) - for (const channelId of extra) await visit("cold", channelId, " to 10k channel") - for (const channelId of channels) await visit("warm", channelId) - for (const channelId of extra) await visit("warm", channelId, " to 10k channel") - await page.context().close() - }) - } -} - -/** An overlay open: `ms`, plus `commitMs` (the frame its patch painted in, see `ReadyResult`). */ -const recordOpen = (metric: string, target: TargetName, result: { ms: number; commitMs: number }) => { - recordLatency(metric, target, result.ms) - recordLatency(`${metric}, commit frame`, target, result.commitMs) -} - -const LAUNCH_WINDOW = "Launch window confirmed" -/** Ada's own message: only own messages offer Delete. */ -const CHECKLIST = "Launch checklist: please claim your items" - -const measureInteractions = async (target: TargetName) => { - const launch = busiestChannels(rich, 10).find((id) => - rich.tables.channels!.some((row) => row.id === id && row.name === "launch"), - ) - const launchId = launch ?? String(rich.tables.channels!.find((row) => row.name === "launch")!.id) - const mediaId = String(rich.tables.channels!.find((row) => row.name === "media")!.id) - const page = await openChat(target, rich, launchId) - const escape = async () => { - await page.keyboard.press("Escape") - await settle(page, 300) - } - await attempt("composer typing", async () => { - await page.getByRole("combobox").first().click() - await settle(page, 200) - for (let index = 1; index <= 20; index++) { - const result = await timeInput(page, { editorLength: index }, () => - page.keyboard.type("abcdefghijklmnopqrst"[index - 1]!), - ) - recordLatency("composer: key to paint", target, result.ms) - } - for (let index = 0; index < 20; index++) await page.keyboard.press("Backspace") - await page.locator("h1, h2").first().click() - await settle(page, 300) - }) - for (const pass of ["first", "repeat"] as const) { - await attempt(`palette ${pass}`, async () => { - const result = await timeInput(page, { selector: '[role="dialog"]' }, () => - page.getByText("Browse channels").first().click(), - ) - recordOpen(`command palette open (${pass})`, target, result) - await escape() - }) - await attempt(`modal ${pass}`, async () => { - await page.getByText(CHECKLIST).hover() - await page.getByRole("toolbar", { name: "Message actions" }).waitFor() - const result = await timeInput(page, { selector: '[role="dialog"], [role="alertdialog"]' }, () => - page.getByRole("button", { name: "Delete message" }).click(), - ) - recordOpen(`modal open: delete message (${pass})`, target, result) - await page.getByRole("button", { name: "Cancel" }).click() - await settle(page, 300) - }) - await attempt(`emoji ${pass}`, async () => { - await page.getByText(LAUNCH_WINDOW).hover() - await page.getByRole("toolbar", { name: "Message actions" }).waitFor() - const result = await timeInput( - page, - { selector: '[role="dialog"][aria-label="Emoji picker"]' }, - () => page.getByRole("button", { name: "Add reaction" }).click(), - ) - recordOpen(`emoji picker open (${pass})`, target, result) - await escape() - }) - } - await attempt("thread", async () => { - const result = await timeInput(page, { text: "CI freeze and release tagging are mine." }, () => - page.getByRole("button", { name: /3 replies/ }).click(), - ) - recordOpen("thread panel open", target, result) - await escape() - }) - await attempt("image viewer", async () => { - await channelLink(page, mediaId).click() - await page.getByText("Moodboard for the launch page").scrollIntoViewIfNeeded() - await settle(page, 300) - const selector = 'img[alt="moodboard-1.png"]' - const before = await page.locator(selector).count() - const result = await timeInput(page, { selector, minCount: before + 1 }, () => - page.getByRole("img", { name: "moodboard-1.png" }).first().click(), - ) - recordOpen("image viewer open", target, result) - }) - await page.context().close() -} - -const measureSidebar = async (target: TargetName) => { - await attempt("sidebar", async () => { - const page = await openChat(target, heavy, heavyGeneral) - const probe = `a[href$="/chat/${heavyGeneral}"]` - for (const [label, delta] of [ - ["down", 200], - ["up", -200], - ] as const) { - const key = `sidebar (500 channels) wheel ${label}` - const body = () => wheelFrames(page, probe, "a, [role=treeitem], li", delta, seconds * 1000) - recordFrames(key, target, await traced(page, body)) - } - await page.context().close() - }) -} - -const measureMemory = async (target: TargetName) => { - await attempt("memory", async () => { - const page = await openChat(target, heavy, heavyBig) - recordLatency("memory: JS heap after heavy channel (MB)", target, await heapMb(page)) - const channels = [...busiestChannels(heavy, 4, [heavyBig]), heavyBig] - for (let index = 0; index < 20; index++) { - const channelId = channels[index % channels.length]! - await timeInput(page, { ids: messagesOf(heavy, channelId) }, () => - channelLink(page, channelId).click(), - ) - await settle(page, 200) - } - recordLatency("memory: JS heap after 20 switches (MB)", target, await heapMb(page)) - await page.context().close() - }) -} - -const measureBundle = (target: TargetName) => { - const files: string[] = [] - const walk = (dir: string) => { - for (const entry of readdirSync(dir)) { - const path = join(dir, entry) - if (statSync(path).isDirectory()) walk(path) - else if (path.endsWith(".js") && !/\/(sw|workbox-[^/]+)\.js$/.test(path)) files.push(path) - } - } - walk(buildDir(target)) - const raw = files.reduce((sum, path) => sum + statSync(path).size, 0) - const gzip = files.reduce((sum, path) => sum + gzipSync(readFileSync(path)).length, 0) - recordLatency("bundle: all JS (KB raw)", target, Math.round(raw / 1024)) - recordLatency("bundle: all JS (KB gzip)", target, Math.round(gzip / 1024)) -} - -// ---------- run ---------- - -for (const target of names) { - if (groups.has("bundle")) measureBundle(target) - for (let run = 1; run <= runs; run++) { - console.log(`${target} run ${run}/${runs}`) - if (groups.has("load")) await measureLoad(target) - if (groups.has("scroll")) await measureScroll(target) - if (groups.has("switch")) await measureSwitch(target) - if (groups.has("interact")) await measureInteractions(target) - if (groups.has("sidebar")) await measureSidebar(target) - if (groups.has("memory") && run <= 2) await measureMemory(target) - } -} - -// ---------- report ---------- - -const percentile = (list: ReadonlyArray, p: number) => { - const sorted = [...list.filter(Number.isFinite)].sort((a, b) => a - b) - if (!sorted.length) return Number.NaN - return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]! -} -const round = (value: number) => Math.round(value * 10) / 10 -const FRAME_BUDGET = 1000 / 120 - -const latencyRows = [...latencies].map(([metric, byTarget]) => ({ - metric, - targets: Object.fromEntries( - [...byTarget].map(([target, samples]) => { - const list = samples.map((sample) => sample.value) - const blank = samples.map((sample) => sample.blankFrames ?? 0) - return [ - target, - { - n: list.length, - p50: round(percentile(list, 50)), - p95: round(percentile(list, 95)), - max: round(Math.max(...list)), - blankFramesMax: Math.max(...blank), - samples: list.map(round), - }, - ] - }), - ), -})) -const sumSplits = (list: ReadonlyArray): TraceSplit | undefined => - list.length === 0 - ? undefined - : list.reduce((total, split) => ({ - busy: total.busy + split.busy, - script: total.script + split.script, - style: total.style + split.style, - layout: total.layout + split.layout, - paint: total.paint + split.paint, - gc: total.gc + split.gc, - other: total.other + split.other, - })) -const frameRows = [...frames].map(([metric, byTarget]) => ({ - metric, - targets: Object.fromEntries( - [...byTarget].map(([target, samples]) => { - const intervals = samples.flatMap((sample) => sample.run.frameMs) - const cost = samples.flatMap((sample) => sample.costMs) - const split = sumSplits(samples.flatMap((sample) => (sample.split ? [sample.split] : []))) - const tracedFrames = Math.max(1, cost.length) - return [ - target, - { - runs: samples.length, - frames: cost.length, - p50: round(percentile(cost, 50)), - p95: round(percentile(cost, 95)), - p99: round(percentile(cost, 99)), - max: round(Math.max(...cost)), - overBudgetPct: round( - (100 * cost.filter((ms) => ms > FRAME_BUDGET).length) / tracedFrames, - ), - intervalP95: round(percentile(intervals, 95)), - intervalMax: round(Math.max(...intervals)), - blankPct: round( - (100 * samples.reduce((sum, sample) => sum + sample.run.blankFrames, 0)) / - Math.max(1, intervals.length), - ), - longTasksPerRun: round( - samples.reduce((sum, sample) => sum + sample.run.longTasks, 0) / samples.length, - ), - pxPerRun: Math.round( - samples.reduce((sum, sample) => sum + sample.run.scrolledPx, 0) / samples.length, - ), - perFrameSplit: split - ? Object.fromEntries( - Object.entries(split).map(([key, ms]) => [ - key, - Math.round((ms / tracedFrames) * 100) / 100, - ]), - ) - : undefined, - }, - ] - }), - ), -})) - -const budgetFor = ( - metric: string, -): { - label: string - met: (foldkit: { p50: number; p95: number; blankFramesMax?: number }, legacy?: { p50: number }) => boolean -} => { - if (/: warm$/.test(metric)) - return { - label: "p50 < 50 ms, 0 blank frames", - met: (f) => f.p50 < 50 && (f.blankFramesMax ?? 0) === 0, - } - if (/: cold$/.test(metric)) return { label: "p50 < 100 ms", met: (f) => f.p50 < 100 } - return { label: "<= legacy p50 (+10%)", met: (f, l) => !l || f.p50 <= l.p50 * 1.1 + 1 } -} - -const cell = (stats: { p50: number; p95: number; max: number } | undefined) => - stats ? `${stats.p50} / ${stats.p95} / ${stats.max}` : "n/a" -const lines: string[] = [ - `# Perf report: ${values.name}`, - "", - `Runs per target: ${runs}. Headless Chromium keeps a 60 Hz rAF even with --disable-frame-rate-limit, so frame cost is measured from a DevTools timeline trace: main-thread task time between consecutive animation frames. 120 fps budget: ${round(FRAME_BUDGET * 100) / 100} ms of work per frame. Wheel input is real (CDP mouseWheel), pointer over the list.`, - "", - "## Frames (main-thread ms per frame: p50 / p95 / max; over = % frames over 8.33 ms; wall = rAF interval p95 / max)", - "", - "| scenario | legacy | legacy over | legacy wall | foldkit | foldkit over | foldkit wall | foldkit blank % | p95 <= 8.33 |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", - ...frameRows.map(({ metric, targets: t }) => { - const legacy = t.legacy - const foldkit = t.foldkit - const wall = (stats: typeof legacy) => (stats ? `${stats.intervalP95} / ${stats.intervalMax}` : "n/a") - return `| ${metric} | ${cell(legacy)} | ${legacy?.overBudgetPct ?? "n/a"}% | ${wall(legacy)} | ${cell(foldkit)} | ${foldkit?.overBudgetPct ?? "n/a"}% | ${wall(foldkit)} | ${foldkit?.blankPct ?? "n/a"} | ${foldkit ? (foldkit.p95 <= FRAME_BUDGET ? "yes" : "no") : "n/a"} |` - }), - "", - "## Main-thread split per frame (ms: script / style / layout / paint / gc / other, busy)", - "", - "| scenario | legacy | foldkit |", - "| --- | --- | --- |", - ...frameRows.map(({ metric, targets: t }) => { - const fmt = (split: Record | undefined) => - split - ? `${split.script} / ${split.style} / ${split.layout} / ${split.paint} / ${split.gc} / ${split.other}, ${split.busy}` - : "n/a" - return `| ${metric} | ${fmt(t.legacy?.perFrameSplit)} | ${fmt(t.foldkit?.perFrameSplit)} |` - }), - "", - "## Latency and size (p50 / p95 / max)", - "", - "| metric | legacy | foldkit | foldkit blank frames (max) | budget | met |", - "| --- | --- | --- | --- | --- | --- |", - ...latencyRows.map(({ metric, targets: t }) => { - const budget = budgetFor(metric) - const met = t.foldkit ? (budget.met(t.foldkit, t.legacy) ? "yes" : "no") : "n/a" - return `| ${metric} | ${cell(t.legacy)} | ${cell(t.foldkit)} | ${t.foldkit?.blankFramesMax ?? "n/a"} | ${budget.label} | ${met} |` - }), - "", - ...(errors.length ? ["## Errors", "", ...errors.map((error) => `- ${error}`), ""] : []), -] - -const reportDir = join(outDir, "perf", values.name) -mkdirSync(reportDir, { recursive: true }) -writeFileSync( - join(reportDir, "report.json"), - JSON.stringify({ name: values.name, runs, frames: frameRows, latency: latencyRows, errors }, null, "\t"), -) -writeFileSync(join(reportDir, "report.md"), lines.join("\n")) -console.log(lines.join("\n")) -console.log(`\nWrote ${reportDir}/report.{json,md}`) - -await browser.close() -for (const server of servers) server.stop(true) -await backend.stop() diff --git a/packages/ui-parity/src/bench/render-trace.ts b/packages/ui-parity/src/bench/render-trace.ts deleted file mode 100644 index 7840c98e0..000000000 --- a/packages/ui-parity/src/bench/render-trace.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { parseArgs } from "node:util" -import { chromium } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { buildDir, FIXTURE_BACKEND_PORT, FIXTURE_ELECTRIC_PORT, targets, type TargetName } from "../config.ts" -import { heavyIds } from "../fixtures/datasets/heavy.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" -import { prepareList } from "./chat-list-probes.ts" - -/** - * Where a scroll frame goes: scrolls the heavy channel up 40 px per frame for 30 frames under a - * CPU profile and a timeline trace, then prints the busiest functions and rendering phases. - * - * PARITY_PORT_BASE=5750 bun run src/bench/render-trace.ts [--target foldkit|legacy] - */ - -const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { target: { type: "string", default: "foldkit" } }, -}) -const target = values.target as TargetName -const dataset = datasets.get("heavy")! -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: "heavy", -}) -const server = serveStatic(buildDir(target), targets[target].port, clerkIdentityFor(dataset)) -const browser = await chromium.launch(BROWSER_LAUNCH_OPTIONS) -const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage() -await page.goto(`http://localhost:${targets[target].port}/${heavyIds.orgSlug}/chat/${heavyIds.bigChannelId}`) -await prepareList(page) - -const cdp = await page.context().newCDPSession(page) -await cdp.send("Profiler.enable") -await cdp.send("Profiler.setSamplingInterval", { interval: 200 }) -await browser.startTracing(page, { categories: ["devtools.timeline"] }) -await cdp.send("Profiler.start") -await page.evaluate(async () => { - let scroller = document.querySelector("[data-id]")!.parentElement! - while ( - !( - scroller.scrollHeight > scroller.clientHeight + 1 && - /(auto|scroll)/.test(getComputedStyle(scroller).overflowY) - ) - ) - scroller = scroller.parentElement! - for (let frame = 0; frame < 30; frame++) { - scroller.scrollTop -= 40 - await new Promise((resolve) => requestAnimationFrame(resolve)) - } -}) -const { profile } = await cdp.send("Profiler.stop") -const trace = JSON.parse((await browser.stopTracing()).toString()) as { - traceEvents: Array<{ ph: string; name: string; dur?: number }> -} - -const selfMs = new Map() -const nodes = new Map(profile.nodes.map((node) => [node.id, node])) -profile.samples?.forEach((id, index) => { - const frame = nodes.get(id)!.callFrame - const key = `${frame.functionName || "(anonymous)"} ${frame.url.split("/").pop()}:${frame.lineNumber + 1}:${frame.columnNumber + 1}` - selfMs.set(key, (selfMs.get(key) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000) -}) -const phases = new Map() -for (const event of trace.traceEvents) - if (event.ph === "X" && event.dur) - phases.set(event.name, (phases.get(event.name) ?? 0) + event.dur / 1000) -const top = (entries: Map, count: number) => - [...entries] - .sort((a, b) => b[1] - a[1]) - .slice(0, count) - .map(([key, ms]) => ` ${ms.toFixed(1).padStart(8)} ms ${key}`) - .join("\n") -console.log(`functions (self time):\n${top(selfMs, 18)}\nphases:\n${top(phases, 10)}`) -await browser.close() -server.stop(true) -await backend.stop() diff --git a/packages/ui-parity/src/bench/sidebar-diag.ts b/packages/ui-parity/src/bench/sidebar-diag.ts deleted file mode 100644 index d43c5fff4..000000000 --- a/packages/ui-parity/src/bench/sidebar-diag.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { chromium } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { - buildDir, - FIXTURE_BACKEND_PORT, - FIXTURE_ELECTRIC_PORT, - fixtureBackendUrl, - fixtureElectricUrl, - targets, - type TargetName, -} from "../config.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" - -/** - * Hit-test probe for the heavy workspace: counts the sidebar elements that get their own paint layer - * and times `elementFromPoint` over the #firehose message list, optionally with CSS experiments. - * - * PARITY_PORT_BASE=8300 bun run src/bench/sidebar-diag.ts [foldkit|legacy] ['name=css' ...] - */ - -const heavy = datasets.get("heavy")! -const target: TargetName = Bun.argv[2] === "legacy" ? "legacy" : "foldkit" -const experiments: ReadonlyArray = [ - ["baseline", ""], - ["no sidebar", "[data-slot=sidebar-content]{display:none!important}"], - ...Bun.argv.slice(3).map((arg): readonly [string, string] => { - const [name = arg, ...css] = arg.split("=") - return [name, css.join("=")] - }), -] - -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: "heavy", -}) -const server = serveStatic(buildDir(target), targets[target].port, clerkIdentityFor(heavy)) -const browser = await chromium.launch({ ...BROWSER_LAUNCH_OPTIONS }) -const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, - serviceWorkers: "block", -}) -const page = await context.newPage() -await page.addInitScript((identity) => { - ;(window as unknown as { __parityClerkIdentity: unknown }).__parityClerkIdentity = identity -}, clerkIdentityFor(heavy)) -for (const origin of [fixtureBackendUrl, new URL(fixtureElectricUrl).origin]) - await page.route(`${origin}/**`, (route) => - route.continue({ headers: { ...route.request().headers(), "x-parity-dataset": "heavy" } }), - ) -const firehose = String(heavy.tables.channels!.find((row) => row.name === "firehose")!.id) -await page.goto(`http://localhost:${targets[target].port}/hazel/chat/${firehose}`) -await page.waitForSelector("[data-id]", { timeout: 30_000 }) -await page.waitForTimeout(3000) - -const layers = await page.evaluate(() => { - const sidebar = document.querySelector("[role=treegrid]")?.closest("[data-slot=sidebar-content]") - const reasons: Record = {} - for (const element of sidebar ? Array.from(sidebar.querySelectorAll("*")) : []) { - const style = getComputedStyle(element) - const why = [ - style.position !== "static" && style.position, - Number(style.opacity) < 1 && "opacity", - style.transform !== "none" && "transform", - style.willChange !== "auto" && "will-change", - style.isolation === "isolate" && "isolate", - style.contain !== "none" && "contain", - ].filter(Boolean) - if (why.length === 0) continue - const slot = element.getAttribute("data-slot") ?? element.getAttribute("slot") ?? "" - const key = `${why.join("+")} <${element.tagName.toLowerCase()} ${slot}>` - reasons[key] = (reasons[key] ?? 0) + 1 - } - return reasons -}) -console.log("sidebar layer-creating elements:", layers) - -const box = await page.locator("[data-id]").last().boundingBox() -const point = { x: (box?.x ?? 800) + 100, y: (box?.y ?? 500) - 50 } -const msPerHit = () => - page.evaluate(({ x, y }) => { - const start = performance.now() - for (let i = 0; i < 2000; i++) document.elementFromPoint(x + (i % 7), y + (i % 5)) - return (performance.now() - start) / 2000 - }, point) - -for (const [name, css] of experiments) { - await page.evaluate((css) => { - document.getElementById("sidebar-diag")?.remove() - const style = document.createElement("style") - style.id = "sidebar-diag" - style.textContent = css - document.head.append(style) - }, css) - await page.waitForTimeout(300) - await msPerHit() - const runs = [await msPerHit(), await msPerHit(), await msPerHit()] - console.log(name.padEnd(16), runs.map((ms) => ms.toFixed(3)).join(" "), "ms per hit test") -} - -await browser.close() -server.stop() -await backend.stop() -process.exit(0) diff --git a/packages/ui-parity/src/bench/style-bisect.ts b/packages/ui-parity/src/bench/style-bisect.ts deleted file mode 100644 index 62cb6c301..000000000 --- a/packages/ui-parity/src/bench/style-bisect.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { parseArgs } from "node:util" -import { chromium } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { buildDir, FIXTURE_BACKEND_PORT, FIXTURE_ELECTRIC_PORT, targets, type TargetName } from "../config.ts" -import { heavyIds } from "../fixtures/datasets/heavy.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" -import { prepareList } from "./chat-list-probes.ts" - -/** - * Finds the CSS rule that makes a DOM insertion expensive. Opens the heavy channel, times - * "insert a div next to a message row + force style and layout", then bisects the stylesheet - * (rules swapped for no-ops in place) down to the rule whose removal makes insertions cheap. - * This found the `has-disabled` rule that restyled ~23k elements per insertion (S4 record). - * - * PARITY_PORT_BASE=5750 bun run src/bench/style-bisect.ts [--target foldkit|legacy] - */ - -const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - target: { type: "string", default: "foldkit" }, - probe: { type: "string", default: "div" }, - strip: { type: "string" }, - }, -}) -const target = values.target as TargetName -const dataset = datasets.get("heavy")! -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: "heavy", -}) -const server = serveStatic(buildDir(target), targets[target].port, clerkIdentityFor(dataset)) -const browser = await chromium.launch(BROWSER_LAUNCH_OPTIONS) -const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage() -await page.goto(`http://localhost:${targets[target].port}/${heavyIds.orgSlug}/chat/${heavyIds.bigChannelId}`) -await prepareList(page) - -const log = await page.evaluate( - ([probeKind, strip]) => { - // `--strip ` deletes every rule containing first, to test a family of rules. - const stripRules = (parent: CSSStyleSheet | CSSGroupingRule): number => { - let removed = 0 - for (let index = parent.cssRules.length - 1; index >= 0; index--) { - const rule = parent.cssRules[index]! - if (rule instanceof CSSGroupingRule && !(rule instanceof CSSStyleRule)) - removed += stripRules(rule) - else if (strip && rule.cssText.includes(strip)) { - parent.deleteRule(index) - removed++ - } - } - return removed - } - const stripped = strip - ? [...document.styleSheets].reduce((sum, sheet) => sum + stripRules(sheet), 0) - : 0 - const row = document.querySelector("[data-id]")! - const where = row.parentElement! - // `row` probes clone a rendered message row's wrapper, which is what scrolling inserts. - const makeProbe = () => - probeKind === "row" - ? ((row.closest("[data-list-key], [data-index]") ?? row).cloneNode(true) as Element) - : document.createElement("div") - const measure = () => { - const started = performance.now() - for (let i = 0; i < 3; i++) { - const probe = makeProbe() - ;(probeKind === "row" - ? row.closest("[data-list-key], [data-index]")!.parentElement! - : where - ).appendChild(probe) - void document.documentElement.offsetHeight - probe.remove() - void document.documentElement.offsetHeight - } - return (performance.now() - started) / 6 - } - interface Slot { - readonly parent: CSSStyleSheet | CSSGroupingRule - readonly index: number - readonly text: string - } - const slots: Slot[] = [] - const walk = (parent: CSSStyleSheet | CSSGroupingRule) => { - for (let index = 0; index < parent.cssRules.length; index++) { - const rule = parent.cssRules[index]! - if ( - rule instanceof CSSGroupingRule && - !(rule instanceof CSSStyleRule) && - rule.cssRules.length > 0 - ) - walk(rule) - else slots.push({ parent, index, text: rule.cssText }) - } - } - for (const sheet of document.styleSheets) walk(sheet) - const toggle = (from: number, to: number, isDisabled: boolean) => { - for (let k = from; k < to; k++) { - const slot = slots[k]! - slot.parent.deleteRule(slot.index) - slot.parent.insertRule(isDisabled ? ".__parity-disabled{}" : slot.text, slot.index) - } - } - const base = measure() - const lines = [`${slots.length} rules (${stripped} stripped), insertion costs ${base.toFixed(1)} ms`] - let low = 0 - let high = slots.length - while (high - low > 1) { - const middle = (low + high) >> 1 - toggle(low, middle, true) - const cost = measure() - toggle(low, middle, false) - if (cost < base / 4) high = middle - else low = middle - } - toggle(low, low + 1, true) - lines.push(`culprit: ${slots[low]!.text.slice(0, 300)}`, `without it: ${measure().toFixed(1)} ms`) - return lines - }, - [values.probe, values.strip ?? ""] as const, -) -console.log(log.join("\n")) -await browser.close() -server.stop(true) -await backend.stop() diff --git a/packages/ui-parity/src/bench/switch-trace.ts b/packages/ui-parity/src/bench/switch-trace.ts deleted file mode 100644 index 6dc091aac..000000000 --- a/packages/ui-parity/src/bench/switch-trace.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { parseArgs } from "node:util" -import { chromium, type Page } from "playwright" -import { startFixtureBackend } from "../backend/server.ts" -import { BROWSER_LAUNCH_OPTIONS } from "../capture.ts" -import { buildDir, FIXTURE_BACKEND_PORT, FIXTURE_ELECTRIC_PORT, targets, type TargetName } from "../config.ts" -import { clerkIdentityFor } from "../fixtures/identity.ts" -import { datasets } from "../scenarios.ts" -import { serveStatic } from "../serve.ts" - -/** - * Where a channel switch goes: walks the busiest channels in `--path` order (the default opens the - * busiest, visits the next, and returns) and traces the last switch under a CPU profile and a timeline - * trace: the busiest functions, each long task with its layouts, and the frames until a row paints. - * - * PARITY_PORT_BASE=5750 bun run src/bench/switch-trace.ts [--target foldkit] [--dataset heavy] [--dir build] [--path 0,1,2,3,f,0,1] - */ - -const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - target: { type: "string", default: "foldkit" }, - dataset: { type: "string", default: "heavy" }, - /** Serve another build of the target (e.g. an unminified one for readable function names). */ - dir: { type: "string" }, - /** Busiest-channel indexes to visit in order (`f` = #firehose); the last visit is traced. */ - path: { type: "string", default: "0,1,0" }, - }, -}) -const target = values.target as TargetName -const dataset = datasets.get(values.dataset)! -const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: dataset.name, -}) -const server = serveStatic(values.dir ?? buildDir(target), targets[target].port, clerkIdentityFor(dataset)) -const browser = await chromium.launch(BROWSER_LAUNCH_OPTIONS) -const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" }) -await context.addInitScript((identity) => { - ;(window as unknown as { __parityClerkIdentity: unknown }).__parityClerkIdentity = identity -}, clerkIdentityFor(dataset)) -const page = await context.newPage() - -const messages = dataset.tables.messages ?? [] -const member = new Set( - (dataset.tables.channel_members ?? []) - .filter((row) => row.userId === dataset.currentUser.id) - .map((row) => String(row.channelId)), -) -const busiest = (dataset.tables.channels ?? []) - .filter((row) => member.has(String(row.id)) && row.type !== "thread" && row.name !== "firehose") - .map((row) => ({ id: String(row.id), count: messages.filter((m) => m.channelId === row.id).length })) - .sort((a, b) => b.count - a.count) - .map((channel) => channel.id) -const firehose = (dataset.tables.channels ?? []).find((row) => row.name === "firehose") -const path = values.path - .split(",") - .map((step) => (step === "f" ? String(firehose?.id) : busiest[Number(step)]!)) -const idsOf = (channelId: string) => - messages.filter((m) => m.channelId === channelId).map((m) => String(m.id)) - -const settle = (quietMs = 500) => - page.evaluate( - (quiet) => - new Promise((resolve) => { - let timer = setTimeout(done, quiet) - const observer = new MutationObserver(() => { - clearTimeout(timer) - timer = setTimeout(done, quiet) - }) - observer.observe(document.body, { childList: true, subtree: true, attributes: true }) - function done() { - observer.disconnect() - resolve() - } - }), - quietMs, - ) - -/** Clicks the channel link and records each frame until a row of the channel is visible. */ -const switchTo = async (p: Page, channelId: string) => { - const ready = p.evaluate(async (ids) => { - const wanted = new Set(ids) - const start = performance.now() - const frames: Array<{ at: number; rows: number; wanted: boolean }> = [] - for (;;) { - await new Promise((resolve) => requestAnimationFrame(resolve)) - const rows = Array.from(document.querySelectorAll("[data-id]")) - const hit = rows.some((row) => { - const rect = row.getBoundingClientRect() - return ( - wanted.has(row.getAttribute("data-id") ?? "") && rect.height > 0 && rect.top < innerHeight - ) - }) - frames.push({ at: Math.round(performance.now() - start), rows: rows.length, wanted: hit }) - if (hit || performance.now() - start > 5000) return frames - } - }, idsOf(channelId)) - await p.locator(`a[href$="/chat/${channelId}"]`).first().click() - return ready -} - -await page.goto(`http://localhost:${targets[target].port}/hazel/chat/${path[0]}`) -await page.waitForSelector("[data-id]", { timeout: 30_000 }) -await settle() -for (const channelId of path.slice(1, -1)) { - await switchTo(page, channelId) - await settle(300) -} - -const cdp = await context.newCDPSession(page) -await cdp.send("Profiler.enable") -await cdp.send("Profiler.setSamplingInterval", { interval: 100 }) -await browser.startTracing(page, { - categories: ["toplevel", "devtools.timeline", "disabled-by-default-devtools.timeline.stack"], -}) -await cdp.send("Profiler.start") -const frames = await switchTo(page, path.at(-1)!) -const { profile } = await cdp.send("Profiler.stop") -const trace = JSON.parse((await browser.stopTracing()).toString()) as { - traceEvents: Array<{ - ph: string - name: string - dur?: number - ts: number - tid: number - args?: { beginData?: { stackTrace?: Array<{ functionName: string; lineNumber: number }> } } - }> -} - -const selfMs = new Map() -const nodes = new Map(profile.nodes.map((node) => [node.id, node])) -const parents = new Map() -for (const node of profile.nodes) for (const child of node.children ?? []) parents.set(child, node.id) -const totalMs = new Map() -const keyOf = (id: number) => { - const frame = nodes.get(id)!.callFrame - return `${frame.functionName || "(anonymous)"} ${frame.url.split("/").pop()}:${frame.lineNumber + 1}` -} -profile.samples?.forEach((id, index) => { - const ms = (profile.timeDeltas?.[index] ?? 0) / 1000 - selfMs.set(keyOf(id), (selfMs.get(keyOf(id)) ?? 0) + ms) - const seen = new Set() - for (let node: number | undefined = id; node !== undefined; node = parents.get(node)) { - const key = keyOf(node) - if (seen.has(key)) continue - seen.add(key) - totalMs.set(key, (totalMs.get(key) ?? 0) + ms) - } -}) -const phases = new Map() -for (const event of trace.traceEvents) - if (event.ph === "X" && event.dur) - phases.set(event.name, (phases.get(event.name) ?? 0) + event.dur / 1000) -const top = (entries: Map, count: number) => - [...entries] - .sort((a, b) => b[1] - a[1]) - .slice(0, count) - .map(([key, ms]) => ` ${ms.toFixed(1).padStart(8)} ms ${key}`) - .join("\n") -// Each long main-thread task with the app functions that dominate it (inclusive sample time). -const sampleTimes: number[] = [] -let clock = profile.startTime -for (const delta of profile.timeDeltas ?? []) sampleTimes.push((clock += delta)) -const mainTid = trace.traceEvents.find((event) => event.name === "FireAnimationFrame")?.tid -const tasks = trace.traceEvents.filter( - (event) => - event.tid === mainTid && - (event.name === "RunTask" || event.name === "ThreadControllerImpl::RunTask") && - (event.dur ?? 0) > 1500, -) -if (tasks.length === 0) - console.log( - [ - ...new Set( - trace.traceEvents.filter((e) => e.tid === mainTid && (e.dur ?? 0) > 1500).map((e) => e.name), - ), - ].join(), - ) -const firstTs = tasks[0]?.ts ?? 0 -for (const task of tasks) { - const inTask = new Map() - profile.samples?.forEach((id, index) => { - const at = sampleTimes[index]! - if (at < task.ts || at > task.ts + task.dur!) return - const seen = new Set() - for (let node: number | undefined = id; node !== undefined; node = parents.get(node)) { - const key = keyOf(node) - if (seen.has(key) || /^\((root|program)\)/.test(key)) continue - seen.add(key) - inTask.set(key, (inTask.get(key) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000) - } - }) - const rendering = trace.traceEvents - .filter( - (event) => - event.tid === mainTid && - /^(Layout|UpdateLayoutTree|Paint|PrePaint)$/.test(event.name) && - event.ts >= task.ts && - event.ts <= task.ts + task.dur!, - ) - .filter((event) => (event.dur ?? 0) > 1000) - .map((event) => { - const stack = event.args?.beginData?.stackTrace - const forced = - stack - ?.slice(0, 4) - .map((frame) => `${frame.functionName}:${frame.lineNumber}`) - .join("<") ?? "frame" - return `${event.name} ${((event.dur ?? 0) / 1000).toFixed(1)} (${forced})` - }) - console.log( - `task +${((task.ts - firstTs) / 1000).toFixed(1)}ms dur ${(task.dur! / 1000).toFixed(1)}ms [${rendering.join(", ")}]`, - ) - console.log(top(inTask, 25)) -} -console.log(`frames: ${JSON.stringify(frames)}`) -console.log(`self:\n${top(selfMs, 30)}\ntotal:\n${top(totalMs, 45)}\nphases:\n${top(phases, 12)}`) -await browser.close() -server.stop(true) -await backend.stop() diff --git a/packages/ui-parity/src/build.ts b/packages/ui-parity/src/build.ts deleted file mode 100644 index cdf6e92ca..000000000 --- a/packages/ui-parity/src/build.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { $ } from "bun" -import { existsSync, rmSync, writeFileSync } from "node:fs" -import { homedir } from "node:os" -import { resolve } from "node:path" -import { - BUILD_STAMP_FILE, - buildDir, - buildEnv, - LEGACY_BASELINE_REF, - PORT_BASE, - repoRoot, - targets, - type TargetName, -} from "./config.ts" - -/** - * Builds a target into `.parity/builds/` (`builds-/` for a non-default `PARITY_PORT_BASE`). - * - * `ref` pins the build to a git commit via a throwaway worktree. Pin the legacy - * baseline (e.g. the last commit before migration work started) so the reference - * UI never drifts while the Foldkit app is being written. - */ -export const buildTarget = async (name: TargetName, options: { readonly ref?: string } = {}) => { - const target = targets[name] - const ref = options.ref ?? (name === "legacy" ? LEGACY_BASELINE_REF : undefined) - let root = repoRoot - if (ref) { - // Outside the repo so root-level tooling (vitest, oxlint, tsc) never crawls a second checkout. - root = resolve(homedir(), ".cache/hazel-ui-parity/worktrees", `${name}-${ref}`) - if (!existsSync(root)) { - await $`git -C ${repoRoot} worktree add --detach ${root} ${ref}` - await $`bun install --frozen-lockfile`.cwd(root) - } - } - const appDir = resolve(root, target.appDir) - if (!existsSync(appDir)) throw new Error(`ui-parity: ${appDir} does not exist (target "${name}")`) - - const dest = buildDir(name) - rmSync(dest, { recursive: true, force: true }) - console.log(`[build] ${name}${ref ? ` @ ${ref}` : ""} → ${dest}`) - await $`bunx vite build --outDir ${dest} --emptyOutDir`.cwd(appDir).env({ ...process.env, ...buildEnv }) - writeFileSync(resolve(dest, BUILD_STAMP_FILE), JSON.stringify({ portBase: PORT_BASE, ref: ref ?? null })) - return dest -} diff --git a/packages/ui-parity/src/capture.ts b/packages/ui-parity/src/capture.ts deleted file mode 100644 index 795a5eed6..000000000 --- a/packages/ui-parity/src/capture.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { chromium, type Browser } from "playwright" -import { - CANONICAL_ORIGIN, - fixtureBackendUrl, - fixtureElectricUrl, - outDir, - targets, - type TargetName, -} from "./config.ts" -import { CALL_LOG_PATH, CAPTURE_HEADER, type RecordedCall } from "./backend/call-log.ts" -import { NETWORK_FIXTURE_HOSTS, networkFixturePath } from "./backend/network.ts" -import { clerkIdentityFor } from "./fixtures/identity.ts" -import { BOX_STYLE_PROPS, collectSnapshot, serializeDom, TEXT_STYLE_PROPS } from "./runtime/snapshot.ts" -import { installDeterminism, waitForVisualQuiet } from "./runtime/stabilize.ts" -import { - datasets, - scenarios, - viewports, - type Scenario, - type ThemeName, - type ViewportName, -} from "./scenarios.ts" - -/** - * One capture = one (scenario, viewport, theme) rendered by one target. - * Output per capture: `.png` + `.json` (structural snapshot + diagnostics). - */ -export interface CaptureVariant { - readonly scenario: Scenario - readonly viewport: ViewportName - readonly theme: ThemeName - readonly id: string -} - -export interface CaptureResult { - readonly variantId: string - readonly ok: boolean - readonly error?: string - readonly consoleErrors: ReadonlyArray - readonly blockedRequests: ReadonlyArray - readonly durationMs: number -} - -export const expandVariants = (filter?: string): CaptureVariant[] => - scenarios - .flatMap((scenario) => - (scenario.viewports ?? ["desktop"]).flatMap((viewport) => - (scenario.themes ?? ["light"]).map((theme) => ({ - scenario, - viewport, - theme, - id: `${scenario.id}--${viewport}--${theme}`, - })), - ), - ) - .filter((variant) => !filter || variant.id.includes(filter) || variant.scenario.area === filter) - -export const captureDir = (run: string, target: string) => join(outDir, "runs", run, target) - -const SEED = 0x4a2e1 - -/** - * Pages are served via `route.fulfill` (canonical origin), which Chromium treats as a - * public address; its Local Network Access checks would then block calls to the - * loopback fixture backend. Everything here is local, so the checks are disabled. - */ -export const BROWSER_LAUNCH_OPTIONS = { - args: [ - "--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights", - ], -} - -/** - * Console noise that does not indicate a broken capture: analytics without a key, the - * Rivet actor endpoint (not part of the fixture backend), and requests the harness itself - * blocked (listed separately in `blockedRequests`). - */ -const IGNORED_CONSOLE_ERRORS = - /PostHog|Failed to load resource: the server responded with a status of 404|\/rivet\/|ERR_BLOCKED_BY_CLIENT/ - -/** - * Signed out, the app's Electric fetch answers 401 itself (no Clerk token) for the collections it - * preloads, which is what an anonymous visitor's console shows too. - */ -const SIGNED_OUT_CONSOLE_ERRORS = - /^\[Live Query Error\] Source collection '\w+' entered error state|^An error occurred while syncing collection: \w+,[\s\S]*HTTP Error 401/ - -/** - * Screenshots until two in a row are byte-identical. Compositor-only motion (the overscroll bounce - * after a wheel scroll hits an edge) is invisible to the in-page settle checks but not to pixels. - */ -const stableScreenshot = async (take: () => Promise) => { - let previous = await take() - for (let attempt = 0; attempt < 10; attempt++) { - const next = await take() - if (next.equals(previous)) return next - previous = next - } - return previous -} - -export const captureTarget = async (options: { - readonly target: TargetName - /** Output folder name under the run; defaults to the target name (self-checks capture one target twice). */ - readonly label?: string - readonly run: string - readonly variants: ReadonlyArray - readonly browser?: Browser -}): Promise => { - const target = targets[options.target] - const dir = captureDir(options.run, options.label ?? options.target) - mkdirSync(dir, { recursive: true }) - const browser = options.browser ?? (await chromium.launch(BROWSER_LAUNCH_OPTIONS)) - const targetOrigin = `http://localhost:${target.port}` - const allowedOrigins = new Set([CANONICAL_ORIGIN, fixtureBackendUrl, new URL(fixtureElectricUrl).origin]) - - const results: CaptureResult[] = [] - for (const variant of options.variants) { - const started = performance.now() - const dataset = datasets.get(variant.scenario.dataset ?? "default")! - const context = await browser.newContext({ - viewport: viewports[variant.viewport], - deviceScaleFactor: 1, - colorScheme: variant.theme, - reducedMotion: "reduce", - timezoneId: "UTC", - locale: "en-US", - serviceWorkers: "block", - extraHTTPHeaders: {}, - }) - const page = await context.newPage() - // Tags every backend request, so the fixture backend can log this capture's calls. - const captureKey = `${options.label ?? options.target}/${variant.id}` - const callLogUrl = `${fixtureBackendUrl}${CALL_LOG_PATH}?capture=${encodeURIComponent(captureKey)}` - page.setDefaultTimeout(5000) - const consoleErrors: string[] = [] - const blockedRequests: string[] = [] - page.on("console", (message) => { - if (message.type() === "error") consoleErrors.push(message.text().slice(0, 300)) - }) - page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message.slice(0, 300)}`)) - - await page.clock.setFixedTime(dataset.now) - await page.addInitScript(installDeterminism, SEED) - // Read by the Clerk stub that `serve.ts` injects, so each scenario signs in as its dataset's user. - await page.addInitScript((identity) => { - ;(window as unknown as { __parityClerkIdentity: unknown }).__parityClerkIdentity = identity - }, clerkIdentityFor(dataset)) - await page.route("**/*", (route) => { - const url = new URL(route.request().url()) - // Link unfurls, GIF CDNs and the YouTube player: answered by the fixture backend. - if (NETWORK_FIXTURE_HOSTS.has(url.hostname)) { - return route - .fetch({ - url: `${fixtureBackendUrl}${networkFixturePath(url)}`, - headers: { ...route.request().headers(), "x-parity-dataset": dataset.name }, - }) - .then((response) => route.fulfill({ response })) - } - if (!allowedOrigins.has(url.origin)) { - blockedRequests.push(`${url.origin}${url.pathname}`) - return route.abort("blockedbyclient") - } - if (url.origin === CANONICAL_ORIGIN) { - return route - .fetch({ url: `${targetOrigin}${url.pathname}${url.search}` }) - .then((response) => route.fulfill({ response })) - } - return route.continue({ - headers: { - ...route.request().headers(), - "x-parity-dataset": dataset.name, - [CAPTURE_HEADER]: captureKey, - }, - }) - }) - - let error: string | undefined - try { - await page.goto(`${CANONICAL_ORIGIN}${variant.scenario.path}`, { waitUntil: "load" }) - await page.evaluate(waitForVisualQuiet, { quietMs: 400, timeoutMs: 8000 }) - if (variant.scenario.steps) { - await variant.scenario.steps(page) - await page.evaluate(waitForVisualQuiet, { quietMs: 300, timeoutMs: 5000 }) - } - const screenshot = await stableScreenshot(() => - page.screenshot({ - fullPage: variant.scenario.fullPage ?? false, - mask: variant.scenario.mask?.(page) as never, - animations: "disabled", - caret: "hide", - }), - ) - writeFileSync(join(dir, `${variant.id}.png`), screenshot) - const snapshot = await page.evaluate(collectSnapshot, { - textProps: [...TEXT_STYLE_PROPS], - boxProps: [...BOX_STYLE_PROPS], - }) - // Behavior and accessibility, as of the screenshot: calls sent so far and the ARIA tree. - const calls: RecordedCall[] = await fetch(callLogUrl).then((response) => response.json()) - const aria = await page.locator("body").ariaSnapshot() - writeFileSync( - join(dir, `${variant.id}.json`), - JSON.stringify({ url: page.url(), ...snapshot, calls, aria }), - ) - writeFileSync(join(dir, `${variant.id}.html`), await page.evaluate(serializeDom)) - } catch (cause) { - error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause) - await page.screenshot({ path: join(dir, `${variant.id}.png`) }).catch(() => undefined) - } - await context.close() - await fetch(callLogUrl, { method: "DELETE" }).catch(() => undefined) - - const result: CaptureResult = { - variantId: variant.id, - ok: !error, - error, - consoleErrors: consoleErrors.filter( - (line) => - !IGNORED_CONSOLE_ERRORS.test(line) && - !(dataset.signedOut && SIGNED_OUT_CONSOLE_ERRORS.test(line)), - ), - blockedRequests: [...new Set(blockedRequests)], - durationMs: Math.round(performance.now() - started), - } - results.push(result) - console.log( - `[capture] ${options.label ?? options.target} ${variant.id} ${result.ok ? "ok" : `FAILED: ${error}`} (${result.durationMs}ms)`, - ) - } - - writeFileSync(join(dir, "_results.json"), JSON.stringify(results, null, 2)) - if (!options.browser) await browser.close() - return results -} diff --git a/packages/ui-parity/src/cli.ts b/packages/ui-parity/src/cli.ts deleted file mode 100644 index b0952479e..000000000 --- a/packages/ui-parity/src/cli.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { existsSync, mkdirSync, readFileSync } from "node:fs" -import { join } from "node:path" -import { parseArgs } from "node:util" -import { chromium } from "playwright" -import { startFixtureBackend } from "./backend/server.ts" -import { buildTarget } from "./build.ts" -import { - BROWSER_LAUNCH_OPTIONS, - captureDir, - captureTarget, - expandVariants, - type CaptureResult, -} from "./capture.ts" -import { codegenFile } from "./codegen.ts" -import { compareVariant } from "./compare.ts" -import { clerkIdentityFor } from "./fixtures/identity.ts" -import { uncoveredRoutes } from "./coverage.ts" -import { - BUILD_STAMP_FILE, - buildDir, - FIXTURE_BACKEND_PORT, - FIXTURE_ELECTRIC_PORT, - outDir, - PORT_BASE, - targets, - type TargetName, -} from "./config.ts" -import { buildSummary, writeReport } from "./report.ts" -import { datasets } from "./scenarios.ts" -import { serveStatic } from "./serve.ts" - -const usage = `ui-parity: compare the legacy React UI against the Foldkit UI - - PARITY_PORT_BASE=4900 offsets every port (default 4790) so worktrees can run in parallel - - bun parity build [--ref ] build a target against the fixture backend - bun parity serve [--dataset default] fixture backend + both builds, for side-by-side browsing - bun parity run [--filter x] [--baseline legacy] [--candidate foldkit] [--run name] [--strict-a11y] - capture both targets, diff, write the report - (--strict-a11y: ARIA tree differences fail too) - bun parity compare --run name [--baseline legacy] [--candidate foldkit] [--strict-a11y] - re-diff an existing run's captures (no recapture) - bun parity selfcheck [--filter x] [--target legacy] capture one target twice; anything not identical is flaky - bun parity capture [--filter x] [--run name] capture one target (screenshots, snapshots, reference DOM) - bun parity codegen [--line N] [--name viewName] reference markup → Foldkit view code - bun parity list list scenario variants - bun parity coverage legacy routes and the scenarios that cover them -` - -const { positionals, values } = parseArgs({ - args: Bun.argv.slice(2), - allowPositionals: true, - options: { - ref: { type: "string" }, - filter: { type: "string" }, - baseline: { type: "string", default: "legacy" }, - candidate: { type: "string", default: "foldkit" }, - target: { type: "string", default: "legacy" }, - run: { type: "string" }, - dataset: { type: "string", default: "default" }, - tolerance: { type: "string", default: "0" }, - "strict-a11y": { type: "boolean", default: false }, - line: { type: "string" }, - name: { type: "string" }, - }, -}) - -const identityFor = (datasetName: string) => { - const dataset = datasets.get(datasetName) - if (!dataset) throw new Error(`unknown dataset ${datasetName}`) - return clerkIdentityFor(dataset) -} - -/** Starts the fixture backend plus a static server for each requested target. */ -const startServers = (names: ReadonlyArray, datasetName = "default") => { - const backend = startFixtureBackend({ - port: FIXTURE_BACKEND_PORT, - electricPort: FIXTURE_ELECTRIC_PORT, - datasets, - defaultDataset: datasetName, - }) - const statics = [...new Set(names)].map((name) => { - const dir = buildDir(name) - if (!existsSync(join(dir, "index.html"))) - throw new Error(`No build for "${name}". Run: bun parity build ${name}`) - // Builds embed the backend URLs; one made for another base would talk to someone else's backend. - const stampFile = join(dir, BUILD_STAMP_FILE) - const stamp = existsSync(stampFile) ? JSON.parse(readFileSync(stampFile, "utf8")) : { portBase: 4790 } - if (stamp.portBase !== PORT_BASE) - throw new Error( - `Build "${name}" targets PARITY_PORT_BASE=${stamp.portBase}, not ${PORT_BASE}. Rebuild: bun parity build ${name}`, - ) - return serveStatic(dir, targets[name].port, identityFor(datasetName)) - }) - return { - backend, - stop: async () => { - for (const server of statics) server.stop(true) - await backend.stop() - }, - } -} - -const timestamp = () => new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) - -const compareRun = (input: { - run: string - baselineLabel: string - candidateLabel: string - variants: ReturnType - baselineResults: CaptureResult[] - candidateResults: CaptureResult[] -}) => { - const runDir = join(outDir, "runs", input.run) - const diffDir = join(runDir, "diff") - mkdirSync(diffDir, { recursive: true }) - // A capture that crashed or logged errors is not evidence of parity, whatever the pixels say. - const unhealthy = new Map( - [...input.baselineResults, ...input.candidateResults] - .filter((result) => !result.ok || result.consoleErrors.length > 0) - .map((result) => [result.variantId, result]), - ) - const comparisons = input.variants.map((variant) => { - const comparison = compareVariant({ - variantId: variant.id, - baselineDir: captureDir(input.run, input.baselineLabel), - candidateDir: captureDir(input.run, input.candidateLabel), - outDir: diffDir, - tolerance: Number(values.tolerance), - strictA11y: values["strict-a11y"], - ignoreBackgroundCalls: variant.scenario.path.startsWith("/dev/gallery/"), - }) - return unhealthy.has(variant.id) && comparison.status !== "missing" - ? { ...comparison, status: "fail" as const } - : comparison - }) - const summary = buildSummary({ - run: input.run, - baseline: input.baselineLabel, - candidate: input.candidateLabel, - variants: input.variants, - comparisons, - baselineResults: input.baselineResults, - candidateResults: input.candidateResults, - }) - const report = writeReport(summary, runDir) - console.log( - `\nidentical ${summary.totals.identical} · pass ${summary.totals.pass} · fail ${summary.totals.fail} · missing ${summary.totals.missing}`, - ) - console.log(`report: ${report}\nsummary: ${join(runDir, "summary.md")}`) - return summary -} - -const command = positionals[0] - -switch (command) { - case "build": { - const name = positionals[1] as TargetName - if (!targets[name]) throw new Error(usage) - await buildTarget(name, { ref: values.ref }) - break - } - - case "serve": { - const available = (Object.keys(targets) as TargetName[]).filter((name) => - existsSync(join(buildDir(name), "index.html")), - ) - const servers = startServers(available, values.dataset) - console.log( - `fixture backend ${servers.backend.url} (dataset: ${values.dataset}, port base ${PORT_BASE})`, - ) - for (const name of available) console.log(`${name.padEnd(16)} http://localhost:${targets[name].port}`) - console.log("ctrl+c to stop") - process.on("SIGINT", async () => { - await servers.stop() - process.exit(0) - }) - await new Promise(() => {}) - break - } - - case "list": { - for (const variant of expandVariants(values.filter)) - console.log(`${variant.id.padEnd(48)} ${variant.scenario.path}`) - break - } - - case "capture": { - const name = (positionals[1] ?? "legacy") as TargetName - const run = values.run ?? `${timestamp()}-capture-${name}` - const servers = startServers([name]) - const results = await captureTarget({ target: name, run, variants: expandVariants(values.filter) }) - await servers.stop() - console.log( - `\n${results.filter((result) => result.ok).length}/${results.length} captured → ${captureDir(run, name)}`, - ) - break - } - - case "codegen": { - const file = positionals[1] - if (!file) throw new Error(usage) - console.log( - codegenFile(file, { line: values.line ? Number(values.line) : undefined, name: values.name }), - ) - break - } - - case "coverage": { - const routes = uncoveredRoutes() - for (const { route, scenarios } of routes) - console.log(`${scenarios.length ? "✓" : "✗"} ${route.padEnd(56)} ${scenarios.join(", ")}`) - console.log( - `\n${routes.filter((entry) => entry.scenarios.length).length}/${routes.length} routes covered`, - ) - break - } - - case "run": { - const baseline = values.baseline as TargetName - const candidate = values.candidate as TargetName - const run = values.run ?? `${timestamp()}-${candidate}-vs-${baseline}` - const variants = expandVariants(values.filter) - const servers = startServers([baseline, candidate]) - const browser = await chromium.launch(BROWSER_LAUNCH_OPTIONS) - // Same-target comparisons (legacy vs legacy) need distinct folders. - const candidateLabel = baseline === candidate ? `${candidate}-b` : candidate - const baselineResults = await captureTarget({ target: baseline, run, variants, browser }) - const candidateResults = await captureTarget({ - target: candidate, - label: candidateLabel, - run, - variants, - browser, - }) - await browser.close() - await servers.stop() - if (servers.backend.log.unmocked.size) - console.log(`\nunmocked RPCs: ${[...servers.backend.log.unmocked].join(", ")}`) - const summary = compareRun({ - run, - baselineLabel: baseline, - candidateLabel, - variants, - baselineResults, - candidateResults, - }) - process.exitCode = summary.totals.fail + summary.totals.missing > 0 ? 1 : 0 - break - } - - case "compare": { - if (!values.run) throw new Error(usage) - const baselineLabel = values.baseline - const candidateLabel = - values.baseline === values.candidate ? `${values.candidate}-b` : values.candidate - const readResults = (label: string): CaptureResult[] => - JSON.parse(readFileSync(join(captureDir(values.run!, label), "_results.json"), "utf8")) - const baselineResults = readResults(baselineLabel) - const captured = new Set(baselineResults.map((result) => result.variantId)) - const summary = compareRun({ - run: values.run, - baselineLabel, - candidateLabel, - variants: expandVariants(values.filter).filter((variant) => captured.has(variant.id)), - baselineResults, - candidateResults: readResults(candidateLabel), - }) - process.exitCode = summary.totals.fail + summary.totals.missing > 0 ? 1 : 0 - break - } - - case "selfcheck": { - const target = values.target as TargetName - const run = values.run ?? `${timestamp()}-selfcheck-${target}` - const variants = expandVariants(values.filter) - const servers = startServers([target]) - const browser = await chromium.launch(BROWSER_LAUNCH_OPTIONS) - const first = await captureTarget({ target, label: `${target}-1`, run, variants, browser }) - const second = await captureTarget({ target, label: `${target}-2`, run, variants, browser }) - await browser.close() - await servers.stop() - const summary = compareRun({ - run, - baselineLabel: `${target}-1`, - candidateLabel: `${target}-2`, - variants, - baselineResults: first, - candidateResults: second, - }) - // Sub-perceptual noise (anti-aliasing) is tolerated; anything visible is flakiness. - const flaky = summary.variants.filter( - (entry) => entry.comparison.status === "fail" || entry.comparison.status === "missing", - ) - console.log( - flaky.length - ? `\nFLAKY: ${flaky.map((entry) => entry.variant.id).join(", ")}` - : "\nAll variants deterministic.", - ) - const failedCaptures = [...first, ...second].filter((result) => !result.ok) - if (failedCaptures.length) - console.log( - `capture failures: ${failedCaptures.map((result) => `${result.variantId}: ${result.error}`).join("\n ")}`, - ) - process.exitCode = flaky.length || failedCaptures.length ? 1 : 0 - break - } - - default: - console.log(usage) -} diff --git a/packages/ui-parity/src/codegen.ts b/packages/ui-parity/src/codegen.ts deleted file mode 100644 index b34ed5dce..000000000 --- a/packages/ui-parity/src/codegen.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { readFileSync } from "node:fs" -import { parseFragment, type DefaultTreeAdapterMap } from "parse5" - -/** - * Translates reference markup (a capture's `.html`) into Foldkit view code. - * - * The output keeps every class string, data-/aria- attribute and SVG path verbatim, - * which is what makes pixel parity mostly automatic. A human or agent then replaces - * literals with Model data, maps lists, and wires events. - * - * - `class` → `h.Class`, `style` → `h.Style`, everything else → `h.Attribute` - * - React Aria bookkeeping (`data-rac`, `data-react-aria-pressable`, generated ids) is dropped - * - class strings used more than once are hoisted into named constants - */ - -type Node = DefaultTreeAdapterMap["childNode"] -type Element = DefaultTreeAdapterMap["element"] - -const DROPPED_ATTRIBUTES = new Set(["data-rac", "data-react-aria-pressable"]) -const isGeneratedId = (name: string, value: string) => name === "id" && /^react-aria|^_r_|«r/.test(value) - -const SVG_CASE: Record = { - clippath: "clipPath", - lineargradient: "linearGradient", - radialgradient: "radialGradient", - foreignobject: "foreignObject", -} - -const quote = (value: string) => JSON.stringify(value) - -const parseStyle = (style: string) => - Object.fromEntries( - style - .split(";") - .map((declaration) => declaration.trim()) - .filter(Boolean) - .map((declaration) => { - const index = declaration.indexOf(":") - return [declaration.slice(0, index).trim(), declaration.slice(index + 1).trim()] - }), - ) - -const renderNode = (node: Node, depth: number, hoisted: ReadonlyMap): string | undefined => { - const indent = "\t".repeat(depth) - if (node.nodeName === "#text") { - const text = (node as DefaultTreeAdapterMap["textNode"]).value.replace(/\s+/g, " ").trim() - return text ? `${indent}${quote(text)}` : undefined - } - if (!("tagName" in node)) return undefined - const element = node as Element - const tag = SVG_CASE[element.tagName] ?? element.tagName - const attributes = element.attrs - .filter((attr) => !DROPPED_ATTRIBUTES.has(attr.name) && !isGeneratedId(attr.name, attr.value)) - .map((attr) => { - if (attr.name === "class") return `h.Class(${hoisted.get(attr.value) ?? quote(attr.value)})` - if (attr.name === "style") return `h.Style(${JSON.stringify(parseStyle(attr.value))})` - return `h.Attribute(${quote(attr.name)}, ${quote(attr.value)})` - }) - const children = (element.childNodes as Node[]) - .map((child) => renderNode(child, depth + 1, hoisted)) - .filter((child): child is string => child !== undefined) - const attrs = attributes.length ? `[${attributes.join(", ")}]` : "[]" - if (!children.length) return `${indent}h.${tag}(${attrs}, [])` - return `${indent}h.${tag}(\n${indent}\t${attrs},\n${indent}\t[\n${children.join(",\n")},\n${indent}\t],\n${indent})` -} - -export interface CodegenOptions { - /** 1-based line in the source file where the element to translate starts. */ - readonly line?: number - /** Name of the generated function. */ - readonly name?: string -} - -export const htmlToFoldkit = (html: string, options: CodegenOptions = {}) => { - const fragment = parseFragment(html, { sourceCodeLocationInfo: true }) - const elements = (nodes: ReadonlyArray): Element[] => - nodes.flatMap((node) => - "tagName" in node ? [node as Element, ...elements(node.childNodes as Node[])] : [], - ) - const all = elements(fragment.childNodes as Node[]) - const root = options.line - ? all.find((element) => element.sourceCodeLocation?.startLine === options.line) - : all[0] - if (!root) throw new Error(`codegen: no element starts on line ${options.line}`) - - const classCounts = new Map() - for (const element of elements([root])) { - const cls = element.attrs.find((attr) => attr.name === "class")?.value - if (cls) classCounts.set(cls, (classCounts.get(cls) ?? 0) + 1) - } - const hoisted = new Map() - for (const [cls, count] of classCounts) { - if (count > 1 && cls.length > 40) hoisted.set(cls, `className${hoisted.size + 1}`) - } - - const constants = [...hoisted].map(([cls, name]) => `const ${name} = ${quote(cls)}`).join("\n") - const body = renderNode(root, 1, hoisted) - return `${constants ? `${constants}\n\n` : ""}export const ${options.name ?? "view"} = (h: HtmlBuilder): Html =>\n${body?.trimStart()}\n` -} - -/** Translates every child of the first element (e.g. an ``'s contents) into Foldkit expressions. */ -export const htmlChildrenToFoldkit = ( - html: string, - depth = 1, -): { - readonly rootAttributes: ReadonlyArray<{ name: string; value: string }> - readonly children: ReadonlyArray -} => { - const fragment = parseFragment(html) - const root = fragment.childNodes.find((node) => "tagName" in node) as Element | undefined - if (!root) throw new Error("codegen: no root element") - const children = (root.childNodes as Node[]) - .map((child) => renderNode(child, depth, new Map())) - .filter((child): child is string => child !== undefined) - return { rootAttributes: root.attrs, children } -} - -export const codegenFile = (path: string, options: CodegenOptions) => - htmlToFoldkit(readFileSync(path, "utf8"), options) diff --git a/packages/ui-parity/src/compare.ts b/packages/ui-parity/src/compare.ts deleted file mode 100644 index 1ff227b5d..000000000 --- a/packages/ui-parity/src/compare.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import pixelmatch from "pixelmatch" -import { PNG } from "pngjs" -import type { RecordedCall } from "./backend/call-log.ts" -import { type AriaDelta, diffAria, diffCallLogs } from "./behavior.ts" -import type { DomSnapshot, Rect, SnapshotNode } from "./runtime/snapshot.ts" - -/** What `capture.ts` writes per variant: the structural snapshot plus the call log and ARIA tree. */ -type CaptureSnapshot = DomSnapshot & { readonly calls?: ReadonlyArray; readonly aria?: string } - -/** - * Compares one variant captured by a baseline target and a candidate target. - * - * Three layers, from "is it identical" to "what exactly to change": - * 1. pixels: strict (any channel difference) and perceptual (pixelmatch, AA ignored) - * 2. regions: differing pixels clustered into boxes, largest first - * 3. structure: text runs + controls paired by identity, with box and style deltas, - * attached to the regions they overlap - * Plus behavior (backend calls, fails the variant) and accessibility (ARIA tree, reported only - * unless `strictA11y`). - */ - -export interface StyleDelta { - readonly prop: string - readonly baseline: string - readonly candidate: string -} - -export interface NodeDelta { - readonly kind: SnapshotNode["kind"] - readonly key: string - readonly label: string - readonly path: { readonly baseline: string; readonly candidate: string } - readonly rect: { readonly baseline: Rect; readonly candidate: Rect } - readonly moved: { readonly dx: number; readonly dy: number; readonly dw: number; readonly dh: number } - readonly styles: ReadonlyArray -} - -export interface Region extends Rect { - readonly pixels: number - /** Keys of node deltas / unmatched nodes overlapping this region. */ - readonly nodes: ReadonlyArray -} - -export interface VariantComparison { - readonly variantId: string - readonly status: "identical" | "pass" | "fail" | "missing" - /** Status from pixels alone, before behavior (and strict a11y) are taken into account. */ - readonly visualStatus: "identical" | "pass" | "fail" | "missing" - readonly width: number - readonly height: number - readonly sizeMismatch: boolean - readonly strictPixels: number - readonly perceptualPixels: number - readonly mismatchPercent: number - readonly regions: ReadonlyArray - readonly deltas: ReadonlyArray - readonly missingInCandidate: ReadonlyArray - readonly extraInCandidate: ReadonlyArray - /** Call-log differences, one readable line each; any line fails the variant. */ - readonly behavior: ReadonlyArray - readonly a11y: AriaDelta - readonly diffImage?: string -} - -const readPng = (path: string) => PNG.sync.read(readFileSync(path)) - -/** Pads an image onto a transparent-magenta canvas so size mismatches show up loudly. */ -const padTo = (png: PNG, width: number, height: number) => { - if (png.width === width && png.height === height) return png.data - const out = Buffer.alloc(width * height * 4) - for (let i = 0; i < out.length; i += 4) out.set([255, 0, 255, 255], i) - for (let y = 0; y < png.height; y++) { - png.data.copy(out, y * width * 4, y * png.width * 4, (y + 1) * png.width * 4) - } - return out -} - -const countStrict = (a: Buffer, b: Buffer) => { - let count = 0 - for (let i = 0; i < a.length; i += 4) { - if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) count++ - } - return count -} - -const CELL = 12 - -/** Clusters diff pixels (red in pixelmatch output) into connected boxes on a coarse grid. */ -const findRegions = (diff: Buffer, width: number, height: number): Array> => { - const cols = Math.ceil(width / CELL) - const rows = Math.ceil(height / CELL) - const cells = new Uint32Array(cols * rows) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const i = (y * width + x) * 4 - // pixelmatch paints differences in diffColor (255,0,0) and diffColorAlt (0,255,0 by default here) - if ( - (diff[i] === 255 && diff[i + 1] === 0 && diff[i + 2] === 0) || - (diff[i] === 0 && diff[i + 1] === 200 && diff[i + 2] === 0) - ) { - cells[Math.floor(y / CELL) * cols + Math.floor(x / CELL)]!++ - } - } - } - const seen = new Uint8Array(cols * rows) - const regions: Array> = [] - for (let start = 0; start < cells.length; start++) { - if (!cells[start] || seen[start]) continue - let minC = cols, - minR = rows, - maxC = 0, - maxR = 0, - pixels = 0 - const stack = [start] - seen[start] = 1 - while (stack.length) { - const cell = stack.pop()! - const c = cell % cols - const r = Math.floor(cell / cols) - pixels += cells[cell]! - minC = Math.min(minC, c) - maxC = Math.max(maxC, c) - minR = Math.min(minR, r) - maxR = Math.max(maxR, r) - for (const [dc, dr] of [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1], - [1, 1], - [-1, -1], - [1, -1], - [-1, 1], - ] as const) { - const nc = c + dc - const nr = r + dr - if (nc < 0 || nr < 0 || nc >= cols || nr >= rows) continue - const next = nr * cols + nc - if (cells[next] && !seen[next]) { - seen[next] = 1 - stack.push(next) - } - } - } - regions.push({ - x: minC * CELL, - y: minR * CELL, - width: Math.min((maxC + 1) * CELL, width) - minC * CELL, - height: Math.min((maxR + 1) * CELL, height) - minR * CELL, - pixels, - }) - } - return regions.sort((a, b) => b.pixels - a.pixels) -} - -const overlaps = (a: Rect, b: Rect) => - a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y - -const POSITION_TOLERANCE = 0.5 - -export const diffSnapshots = (baseline: DomSnapshot, candidate: DomSnapshot) => { - const candidateByKey = new Map(candidate.nodes.map((node) => [node.key, node])) - const baselineKeys = new Set(baseline.nodes.map((node) => node.key)) - const deltas: NodeDelta[] = [] - const missingInCandidate: SnapshotNode[] = [] - - for (const base of baseline.nodes) { - const cand = candidateByKey.get(base.key) - if (!cand) { - missingInCandidate.push(base) - continue - } - const moved = { - dx: Math.round((cand.rect.x - base.rect.x) * 10) / 10, - dy: Math.round((cand.rect.y - base.rect.y) * 10) / 10, - dw: Math.round((cand.rect.width - base.rect.width) * 10) / 10, - dh: Math.round((cand.rect.height - base.rect.height) * 10) / 10, - } - const styles = Object.keys(base.styles) - .filter((prop) => base.styles[prop] !== cand.styles[prop]) - .map((prop) => ({ - prop, - baseline: base.styles[prop]!, - candidate: cand.styles[prop] ?? "(unset)", - })) - const isMoved = Object.values(moved).some((value) => Math.abs(value) > POSITION_TOLERANCE) - if (isMoved || styles.length > 0) { - deltas.push({ - kind: base.kind, - key: base.key, - label: base.label, - path: { baseline: base.path, candidate: cand.path }, - rect: { baseline: base.rect, candidate: cand.rect }, - moved, - styles, - }) - } - } - const extraInCandidate = candidate.nodes.filter((node) => !baselineKeys.has(node.key)) - return { deltas, missingInCandidate, extraInCandidate } -} - -export const compareVariant = (options: { - readonly variantId: string - readonly baselineDir: string - readonly candidateDir: string - readonly outDir: string - /** Perceptual pixels allowed before the variant fails. Pixel-perfect means 0. */ - readonly tolerance?: number - /** Fail the variant on ARIA snapshot differences too (`--strict-a11y`). */ - readonly strictA11y?: boolean - /** Skip app-root background calls (gallery pages, see `diffCallLogs`). */ - readonly ignoreBackgroundCalls?: boolean -}): VariantComparison => { - const { variantId } = options - const baselinePng = join(options.baselineDir, `${variantId}.png`) - const candidatePng = join(options.candidateDir, `${variantId}.png`) - if (!existsSync(baselinePng) || !existsSync(candidatePng)) { - return { - variantId, - status: "missing", - visualStatus: "missing", - width: 0, - height: 0, - sizeMismatch: false, - strictPixels: 0, - perceptualPixels: 0, - mismatchPercent: 100, - regions: [], - deltas: [], - missingInCandidate: [], - extraInCandidate: [], - behavior: [], - a11y: { missing: [], extra: [] }, - } - } - - const a = readPng(baselinePng) - const b = readPng(candidatePng) - const width = Math.max(a.width, b.width) - const height = Math.max(a.height, b.height) - const aData = padTo(a, width, height) - const bData = padTo(b, width, height) - const diff = new PNG({ width, height }) - const strictPixels = countStrict(aData, bData) - const perceptualPixels = pixelmatch(aData, bData, diff.data, width, height, { - threshold: 0.1, - includeAA: false, - alpha: 0.15, - diffColor: [255, 0, 0], - diffColorAlt: [0, 200, 0], - }) - const diffImage = `${variantId}.diff.png` - writeFileSync(join(options.outDir, diffImage), PNG.sync.write(diff)) - - const readSnapshot = (dir: string): CaptureSnapshot | undefined => { - const path = join(dir, `${variantId}.json`) - return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined - } - const baseSnap = readSnapshot(options.baselineDir) - const candSnap = readSnapshot(options.candidateDir) - const structural = - baseSnap && candSnap - ? diffSnapshots(baseSnap, candSnap) - : { deltas: [], missingInCandidate: [], extraInCandidate: [] } - - const regions: Region[] = findRegions(diff.data, width, height) - .slice(0, 20) - .map((region) => ({ - ...region, - nodes: [ - ...structural.deltas - .filter( - (delta) => - overlaps(region, delta.rect.baseline) || overlaps(region, delta.rect.candidate), - ) - .map((delta) => delta.key), - ...structural.missingInCandidate - .filter((node) => overlaps(region, node.rect)) - .map((node) => `missing ${node.key}`), - ...structural.extraInCandidate - .filter((node) => overlaps(region, node.rect)) - .map((node) => `extra ${node.key}`), - ].slice(0, 12), - })) - - const behavior = diffCallLogs(baseSnap?.calls ?? [], candSnap?.calls ?? [], { - ignoreBackground: options.ignoreBackgroundCalls, - }) - const a11y = diffAria(baseSnap?.aria ?? "", candSnap?.aria ?? "") - const a11yFails = options.strictA11y && (a11y.missing.length > 0 || a11y.extra.length > 0) - - const tolerance = options.tolerance ?? 0 - const pixelStatus = - strictPixels === 0 - ? "identical" - : perceptualPixels <= tolerance && a.width === b.width && a.height === b.height - ? "pass" - : "fail" - return { - variantId, - status: behavior.length || a11yFails ? "fail" : pixelStatus, - visualStatus: pixelStatus, - width, - height, - sizeMismatch: a.width !== b.width || a.height !== b.height, - strictPixels, - perceptualPixels, - mismatchPercent: Math.round((perceptualPixels / (width * height)) * 100_000) / 1000, - regions, - ...structural, - behavior, - a11y, - diffImage, - } -} diff --git a/packages/ui-parity/src/config.ts b/packages/ui-parity/src/config.ts deleted file mode 100644 index b88fe6063..000000000 --- a/packages/ui-parity/src/config.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { resolve } from "node:path" - -export const repoRoot = resolve(import.meta.dir, "../../..") -export const parityRoot = resolve(import.meta.dir, "..") -/** Gitignored working area: builds, captures, reports. */ -export const outDir = resolve(parityRoot, ".parity") - -/** - * Every port is an offset from `PARITY_PORT_BASE` (default 4790), so parallel worktrees can run - * the harness at once (e.g. 4900, 5000). Builds embed the backend URLs, so they are kept per base. - */ -export const DEFAULT_PORT_BASE = 4790 -/** Ports Chromium refuses to load (net::ERR_UNSAFE_PORT), plus 5000 and 7000 (macOS AirPlay Receiver). */ -const CHROMIUM_UNSAFE_PORTS = new Set([ - 5000, 7000, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, - 6669, 6679, 6697, 10080, -]) -export const PORT_BASE = (() => { - const raw = process.env.PARITY_PORT_BASE - if (!raw) return DEFAULT_PORT_BASE - const base = Number(raw) - if (!Number.isInteger(base) || base < 1024 || base > 65000) - throw new Error(`ui-parity: PARITY_PORT_BASE must be an integer port base, got "${raw}"`) - const unsafe = [0, 1, 2, 3, 4] - .map((offset) => base + offset) - .filter((port) => CHROMIUM_UNSAFE_PORTS.has(port)) - if (unsafe.length) - throw new Error(`ui-parity: PARITY_PORT_BASE=${base} reaches blocked port ${unsafe.join(", ")}`) - return base -})() - -export const FIXTURE_BACKEND_PORT = PORT_BASE -/** Electric gets its own origin so shape polling never competes with RPC for connections. */ -export const FIXTURE_ELECTRIC_PORT = PORT_BASE + 3 -/** - * Every target is loaded through this origin (requests are proxied to the target's port), so - * anything that prints `location.origin` renders identically across targets. Playwright fulfils it - * in-browser and nothing binds it, so it stays fixed for every base and captures match across worktrees. - */ -export const CANONICAL_ORIGIN = "http://localhost:4800" -export const fixtureBackendUrl = `http://localhost:${FIXTURE_BACKEND_PORT}` -export const fixtureElectricUrl = `http://localhost:${FIXTURE_ELECTRIC_PORT}/v1/shape` - -/** - * The pinned reference UI. `bun run parity build legacy` builds this commit unless `--ref` is given. - * Move it only in a dedicated re-pin commit, with legacy-vs-legacy parity showing what changed. - */ -export const LEGACY_BASELINE_REF = "639aa8d26" - -export type TargetName = "legacy" | "foldkit" - -export interface Target { - readonly name: TargetName - readonly port: number - /** App directory relative to the repo root (inside the worktree for pinned refs). */ - readonly appDir: string - /** Vite output, relative to appDir. */ - readonly distDir: string -} - -export const targets: Record = { - legacy: { name: "legacy", port: PORT_BASE + 1, appDir: "apps/web", distDir: "dist" }, - // Placeholder until the Foldkit app exists; same contract: a Vite app reading the same env vars. - foldkit: { name: "foldkit", port: PORT_BASE + 2, appDir: "apps/web-foldkit", distDir: "dist" }, -} - -/** Env both apps are built with: every backend points at the fixture server, analytics off. */ -export const buildEnv = { - VITE_BACKEND_URL: fixtureBackendUrl, - VITE_ELECTRIC_URL: fixtureElectricUrl, - VITE_CLUSTER_URL: `${fixtureBackendUrl}/cluster`, - VITE_RIVET_URL: `${fixtureBackendUrl}/rivet`, - VITE_R2_PUBLIC_URL: `${fixtureBackendUrl}/r2`, - VITE_CLERK_PUBLISHABLE_KEY: "pk_test_cGFyaXR5LmNsZXJrLmludmFsaWQk", - VITE_PUBLIC_POSTHOG_KEY: "", - VITE_PUBLIC_POSTHOG_HOST: "", -} as const - -export const buildDir = (target: TargetName) => - resolve(outDir, PORT_BASE === DEFAULT_PORT_BASE ? "builds" : `builds-${PORT_BASE}`, target) - -/** Written into every build so a build is never served against a backend on another base. */ -export const BUILD_STAMP_FILE = ".parity-build.json" diff --git a/packages/ui-parity/src/coverage.ts b/packages/ui-parity/src/coverage.ts deleted file mode 100644 index 4ae19c618..000000000 --- a/packages/ui-parity/src/coverage.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { execFileSync } from "node:child_process" -import { LEGACY_BASELINE_REF, repoRoot } from "./config.ts" -import { scenarios } from "./scenarios.ts" - -/** - * Lists legacy routes (read from the pinned baseline, since `apps/web` is gone) that no scenario visits. This is the migration to-do list: - * a screen is only "ported" once a scenario captures it and the diff is clean. - */ -const DEV_ONLY = /^\/(ui|dev)(\/|$)/ - -export const uncoveredRoutes = () => { - const routeTree = execFileSync("git", ["show", `${LEGACY_BASELINE_REF}:apps/web/src/routeTree.gen.ts`], { - cwd: repoRoot, - encoding: "utf8", - }) - const block = routeTree.match(/interface FileRoutesByFullPath \{([\s\S]*?)\n\}/)?.[1] ?? "" - const routes = [ - ...new Set([...block.matchAll(/'([^']+)':/g)].map((match) => match[1]!.replace(/(.)\/$/, "$1"))), - ] - const toPattern = (route: string) => - new RegExp(`^${route.replace(/\/\$$/, "(/.*)?").replace(/\$[^/]+/g, "[^/]+")}/?$`) - return routes - .filter((route) => !DEV_ONLY.test(route)) - .map((route) => ({ - route, - scenarios: scenarios - .filter((scenario) => toPattern(route).test(scenario.path)) - .map((scenario) => scenario.id), - })) -} diff --git a/packages/ui-parity/src/fixtures/dataset.ts b/packages/ui-parity/src/fixtures/dataset.ts deleted file mode 100644 index 279a03914..000000000 --- a/packages/ui-parity/src/fixtures/dataset.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { CurrentUser } from "@hazel/domain" - -/** A row as it lives in Postgres. Dates are `Date` objects; JSON columns are plain objects. */ -export type Row = Record - -/** Electric table names synced by the web app (`apps/web/src/db/collections.ts`). */ -export type TableName = - | "attachments" - | "bot_commands" - | "bot_installations" - | "bots" - | "channel_members" - | "channel_sections" - | "channels" - | "chat_sync_channel_links" - | "chat_sync_connections" - | "chat_sync_message_links" - | "connect_conversation_channels" - | "connect_conversations" - | "connect_participants" - | "custom_emojis" - | "integration_connections" - | "message_reactions" - | "messages" - | "notifications" - | "organization_members" - | "organizations" - | "pinned_messages" - | "typing_indicators" - | "user_presence_status" - | "users" - -/** - * Everything both frontends see during a parity run. The same dataset feeds the - * legacy React app and the Foldkit app, so any visual difference is a UI difference. - */ -export interface Dataset { - readonly name: string - /** Frozen wall clock for the browser, so relative timestamps render identically. */ - readonly now: Date - readonly currentUser: typeof CurrentUser.Schema.Type & { readonly clerkUserId: string } - readonly clerkOrgId: string | null - /** Anonymous visitor: Clerk has no session and authenticated RPCs fail as they would without a token. */ - readonly signedOut?: boolean - readonly tables: Partial>> - /** Canned RPC successes keyed by RPC tag. Values are the decoded (Type-side) success value. */ - readonly rpc: Readonly unknown>> - /** - * Canned HTTP API successes keyed by `METHOD /path` (`GET /integrations/resources//discord/guilds`), - * returned as JSON. Every other HTTP API request still answers 404. - */ - readonly http?: Readonly unknown>> - /** Third-party responses the fixture backend stands in for (`backend/network.ts`), keyed by request. */ - readonly network?: NetworkFixtures - /** Rivet message actors (`backend/rivet.ts`) keyed by message id: the events each one streams. */ - readonly actors?: Readonly> -} - -export interface NetworkFixtures { - /** `link-preview.hazel.sh/link-preview?url=`: the worker's `LinkPreviewData`. */ - readonly linkPreviews?: Readonly> - /** `link-preview.hazel.sh/tweet?id=`: the syndication API's tweet JSON. */ - readonly tweets?: Readonly> -} - -/** - * What a message actor sends after a client connects: its `getState` snapshot, then each event in - * order. A script that stops before `completed` leaves the message streaming. - */ -export interface ActorScript { - readonly state: Readonly> - readonly events: ReadonlyArray<{ readonly name: string; readonly payload: Readonly> }> -} diff --git a/packages/ui-parity/src/fixtures/datasets/chat-sync-discord.ts b/packages/ui-parity/src/fixtures/datasets/chat-sync-discord.ts deleted file mode 100644 index 778a0947f..000000000 --- a/packages/ui-parity/src/fixtures/datasets/chat-sync-discord.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ChatSyncChannelLinkExistsError, ChatSyncChannelLinkResponse, ChatSyncConnectionExistsError, ChatSyncConnectionResponse } from "@hazel/domain/rpc" -import { ChannelId, ExternalChannelId, OrganizationId, SyncConnectionId, TransactionId } from "@hazel/schema" -import { Effect, Schema } from "effect" -import type { Dataset } from "../dataset.ts" -import { defaultIds } from "./default.ts" -import { integrationsDataset } from "./integrations.ts" -import { channelLinkRows, chatSyncIds, connectionRows } from "./integrations-rpc.ts" - -/** - * The integrations workspace with Discord authorized: the add-connection and link-channel modals - * list guilds and channels from the HTTP API. Creating answers per pick, so one dataset shows every - * outcome: success, a typed "already exists" error, and a request that never settles (submitting). - */ - -const now = integrationsDataset.now -const { orgId } = defaultIds - -/** The community connection's guild (see `connectionRows`). */ -const COMMUNITY_GUILD_ID = "918273645500120" - -export const discordGuilds = { - existing: { id: COMMUNITY_GUILD_ID, name: "Hazel Community", icon: null, owner: true }, - created: { id: "918273645500901", name: "Design Systems Guild", icon: null, owner: true }, - pending: { id: "918273645500902", name: "Weekend Gamers", icon: null, owner: false }, -} - -const discordChannel = (id: string, name: string) => ({ - id, - guildId: COMMUNITY_GUILD_ID, - name, - type: 0, - parentId: null, -}) - -/** `general` is already linked to #general (`channelLinkRows`), so linking it again fails. */ -export const discordChannels = { - existing: discordChannel("555000111222330", "general"), - created: discordChannel("555000111222401", "announcements"), - pending: discordChannel("555000111222402", "voice-chat-text"), -} - -const transactionId = Schema.decodeSync(TransactionId)(1) -const field = (payload: unknown, key: string): unknown => - typeof payload === "object" && payload !== null && key in payload ? Reflect.get(payload, key) : undefined - -const createConnection = (payload: unknown) => { - const externalWorkspaceId = field(payload, "externalWorkspaceId") - if (externalWorkspaceId === discordGuilds.pending.id) return Effect.never - if (externalWorkspaceId === discordGuilds.existing.id) - return Effect.fail( - new ChatSyncConnectionExistsError({ - organizationId: Schema.decodeSync(OrganizationId)(orgId), - provider: "discord", - externalWorkspaceId: COMMUNITY_GUILD_ID, - }), - ) - const [row] = connectionRows(now) - return new ChatSyncConnectionResponse({ data: row, transactionId }) -} - -const createChannelLink = (payload: unknown) => { - const externalChannelId = field(payload, "externalChannelId") - if (externalChannelId === discordChannels.pending.id) return Effect.never - if (externalChannelId === discordChannels.existing.id) - return Effect.fail( - new ChatSyncChannelLinkExistsError({ - syncConnectionId: Schema.decodeSync(SyncConnectionId)(chatSyncIds.community), - hazelChannelId: Schema.decodeSync(ChannelId)(String(field(payload, "hazelChannelId"))), - externalChannelId: Schema.decodeSync(ExternalChannelId)(discordChannels.existing.id), - }), - ) - const [row] = channelLinkRows(now) - return new ChatSyncChannelLinkResponse({ data: row, transactionId }) -} - -const resources = `/integrations/resources/${orgId}/discord` - -export const chatSyncDiscordDataset: Dataset = { - ...integrationsDataset, - name: "chat-sync-discord", - rpc: { - ...integrationsDataset.rpc, - "chatSync.connection.create": createConnection, - "chatSync.channelLink.create": createChannelLink, - }, - http: { - [`GET ${resources}/guilds`]: () => ({ guilds: Object.values(discordGuilds) }), - [`GET ${resources}/guilds/${COMMUNITY_GUILD_ID}/channels`]: () => ({ - channels: Object.values(discordChannels), - }), - }, -} diff --git a/packages/ui-parity/src/fixtures/datasets/default.ts b/packages/ui-parity/src/fixtures/datasets/default.ts deleted file mode 100644 index fa25ddc52..000000000 --- a/packages/ui-parity/src/fixtures/datasets/default.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" - -/** - * The baseline workspace: one organization, a handful of teammates, a few - * channels and a populated #general. Most scenarios run against this. - */ - -const now = new Date("2026-03-12T15:00:00.000Z") -const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000) - -const orgId = stableId("org:hazel") - -const people = [ - { key: "ada", firstName: "Ada", lastName: "Lovelace", email: "ada@hazel.test", role: "owner" }, - { key: "grace", firstName: "Grace", lastName: "Hopper", email: "grace@hazel.test", role: "admin" }, - { key: "alan", firstName: "Alan", lastName: "Turing", email: "alan@hazel.test", role: "member" }, - { - key: "margaret", - firstName: "Margaret", - lastName: "Hamilton", - email: "margaret@hazel.test", - role: "member", - }, - { key: "linus", firstName: "Linus", lastName: "Torvalds", email: "linus@hazel.test", role: "member" }, -] as const - -const userId = (key: (typeof people)[number]["key"]) => stableId(`user:${key}`) - -const users: Row[] = people.map((person) => ({ - id: userId(person.key), - externalId: `user_${person.key}`, - email: person.email, - firstName: person.firstName, - lastName: person.lastName, - avatarUrl: null, - userType: "user", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: minutesAgo(60 * 24 * 90), - updatedAt: null, - deletedAt: null, -})) - -const organizations: Row[] = [ - { - id: orgId, - name: "Hazel Labs", - slug: "hazel", - logoUrl: null, - settings: null, - isPublic: false, - createdAt: minutesAgo(60 * 24 * 90), - updatedAt: null, - deletedAt: null, - }, -] - -const organizationMembers: Row[] = people.map((person) => ({ - id: stableId(`org-member:${person.key}`), - organizationId: orgId, - userId: userId(person.key), - role: person.role, - nickname: null, - joinedAt: minutesAgo(60 * 24 * 90), - invitedBy: null, - deletedAt: null, - createdAt: minutesAgo(60 * 24 * 90), -})) - -const projectsSectionId = stableId("section:projects") -const channelSections: Row[] = [ - { - id: projectsSectionId, - organizationId: orgId, - name: "Projects", - order: 0, - createdAt: minutesAgo(60 * 24 * 60), - updatedAt: null, - deletedAt: null, - }, -] - -const channelDefs = [ - { key: "general", name: "general", type: "public", sectionId: null }, - { key: "random", name: "random", type: "public", sectionId: null }, - { key: "design", name: "design", type: "public", sectionId: projectsSectionId }, - { key: "engineering", name: "engineering", type: "public", sectionId: projectsSectionId }, - { key: "leadership", name: "leadership", type: "private", sectionId: null }, - { key: "dm-grace", name: "Grace Hopper", type: "single", sectionId: null }, -] as const - -const channelId = (key: (typeof channelDefs)[number]["key"]) => stableId(`channel:${key}`) - -const channels: Row[] = channelDefs.map((channel) => ({ - id: channelId(channel.key), - name: channel.name, - icon: null, - type: channel.type, - organizationId: orgId, - parentChannelId: null, - sectionId: channel.sectionId, - createdAt: minutesAgo(60 * 24 * 60), - updatedAt: null, - deletedAt: null, -})) - -const membership = ( - channelKey: (typeof channelDefs)[number]["key"], - personKey: (typeof people)[number]["key"], -) => ({ - id: stableId(`channel-member:${channelKey}:${personKey}`), - channelId: channelId(channelKey), - userId: userId(personKey), - isHidden: false, - isMuted: false, - isFavorite: channelKey === "design" && personKey === "ada", - lastSeenMessageId: null, - notificationCount: channelKey === "engineering" && personKey === "ada" ? 3 : 0, - joinedAt: minutesAgo(60 * 24 * 60), - createdAt: minutesAgo(60 * 24 * 60), - deletedAt: null, -}) - -const channelMembers: Row[] = [ - ...(["general", "random", "design", "engineering"] as const).flatMap((channel) => - people.map((person) => membership(channel, person.key)), - ), - membership("leadership", "ada"), - membership("leadership", "grace"), - membership("dm-grace", "ada"), - membership("dm-grace", "grace"), -] - -const generalThread: ReadonlyArray = [ - ["grace", 180, "Morning all! Standup notes are in the doc, please skim before 10."], - ["alan", 176, "Thanks Grace. I'll take the parser refactor today."], - [ - "margaret", - 170, - "Reminder: the release freeze starts Thursday. Anything risky should land before then.", - ], - ["linus", 168, "Is the CI flake on `main` known? Seeing intermittent timeouts in the sync tests."], - ["grace", 166, "Known, tracking it. It's the Electric reconnect test, should be fixed by lunch."], - [ - "ada", - 120, - "Shipped the new onboarding copy. Feedback welcome!\n\n- shorter intro\n- clearer invite step", - ], - ["alan", 118, "Looks great. The invite step reads much better now."], - ["margaret", 60, "Can someone review **#412**? It touches the notification fan-out."], - ["ada", 42, "On it."], - ["linus", 12, "Heading out early today, back online tomorrow morning."], - ["grace", 3, "Have a good one!"], -] - -const messages: Row[] = generalThread.map(([author, ago, content], index) => ({ - id: stableId(`message:general:${index}`), - channelId: channelId("general"), - conversationId: null, - authorId: userId(author), - content, - embeds: null, - replyToMessageId: null, - threadChannelId: null, - createdAt: minutesAgo(ago), - updatedAt: null, - deletedAt: null, -})) - -const messageReactions: Row[] = [ - { message: 5, person: "grace", emoji: "🎉" }, - { message: 5, person: "alan", emoji: "🎉" }, - { message: 5, person: "margaret", emoji: "👍" }, - { message: 8, person: "margaret", emoji: "🙏" }, -].map(({ message, person, emoji }) => ({ - id: stableId(`reaction:${message}:${person}:${emoji}`), - messageId: stableId(`message:general:${message}`), - channelId: channelId("general"), - conversationId: null, - userId: userId(person as (typeof people)[number]["key"]), - emoji, - createdAt: minutesAgo(30), -})) - -const presence: Row[] = people.map((person, index) => ({ - id: stableId(`presence:${person.key}`), - userId: userId(person.key), - status: (["online", "online", "away", "busy", "offline"] as const)[index], - customMessage: null, - statusEmoji: null, - statusExpiresAt: null, - activeChannelId: null, - suppressNotifications: false, - updatedAt: minutesAgo(5), - lastSeenAt: minutesAgo(5), -})) - -const ada = people[0] - -export const defaultDataset: Dataset = { - name: "default", - now, - clerkOrgId: "org_hazel", - currentUser: { - id: userId("ada"), - clerkUserId: "user_ada", - organizationId: orgId, - role: "owner", - firstName: ada.firstName, - lastName: ada.lastName, - email: ada.email, - isOnboarded: true, - timezone: "UTC", - settings: null, - } as Dataset["currentUser"], - tables: { - users, - organizations, - organization_members: organizationMembers, - channel_sections: channelSections, - channels, - channel_members: channelMembers, - messages, - message_reactions: messageReactions, - user_presence_status: presence, - }, - rpc: {}, -} - -/** Handy references for scenarios. */ -export const defaultIds = { - orgSlug: "hazel", - orgId, - channel: channelId, - user: userId, -} diff --git a/packages/ui-parity/src/fixtures/datasets/embeds.ts b/packages/ui-parity/src/fixtures/datasets/embeds.ts deleted file mode 100644 index 4e6141881..000000000 --- a/packages/ui-parity/src/fixtures/datasets/embeds.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { ActorScript, Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { richDataset } from "./rich.ts" -import { asset, at, orgId, userId as richUserId, type PersonKey } from "./rich/base.ts" - -/** - * The `rich` workspace plus message content that reaches past the Hazel backend: link previews and - * tweets (the link-preview worker), YouTube, GIFs, and AI replies streamed by the Rivet message - * actor. `backend/network.ts` and `backend/rivet.ts` answer from `network` and `actors` below. - */ - -export const embedChannelKeys = ["unfurls", "tweets", "videos", "gifs", "ai-replies", "ai-live"] as const -export type EmbedChannelKey = (typeof embedChannelKeys)[number] - -const channelId = (key: EmbedChannelKey) => stableId(`embeds:channel:${key}`) -const messageId = (key: string) => stableId(`embeds:message:${key}`) -const assistantId = stableId("embeds:user:assistant") - -const message = (input: { - readonly key: string - readonly channel: EmbedChannelKey - readonly author: PersonKey | "assistant" - readonly time: string - readonly content: string - readonly embeds?: ReadonlyArray> -}): Row => ({ - id: messageId(input.key), - channelId: channelId(input.channel), - conversationId: null, - authorId: input.author === "assistant" ? assistantId : richUserId(input.author), - content: input.content, - embeds: input.embeds ?? null, - replyToMessageId: null, - threadChannelId: null, - createdAt: at(0, input.time), - updatedAt: null, - deletedAt: null, -}) - -// MARK: Link previews - -const syncArticle = "https://blog.example.com/posts/sync-engine" -const plainArticle = "https://notes.example.org/launch-retro" - -const linkPreviews: Record = { - [syncArticle]: { - url: syncArticle, - title: "How we built a sync engine on Postgres logical replication", - description: - "Shapes, offsets and the long-poll loop behind realtime collections, and what we learned running it for a year.", - image: { url: asset("previews/sync-engine-1200x630.png") }, - publisher: "Example Engineering", - }, - [plainArticle]: { - url: plainArticle, - title: "Launch retro: what went well", - publisher: "Notes", - }, -} - -// MARK: Tweets - -const tweetId = "1890000000000000001" -const missingTweetId = "1890000000000000002" - -/** A syndication-API tweet with entity indices computed from the text, as Twitter sends them. */ -const buildTweet = () => { - const text = - "Realtime sync just landed in Hazel. Read the write-up: https://t.co/hz4sync #buildinpublic cc @electric_sql" - const span = (part: string) => { - const start = text.indexOf(part) - return [start, start + part.length] - } - return { - __typename: "Tweet", - id_str: tweetId, - lang: "en", - created_at: "2026-03-11T18:30:00.000Z", - text, - display_text_range: [0, text.length], - entities: { - hashtags: [{ indices: span("#buildinpublic"), text: "buildinpublic" }], - user_mentions: [ - { indices: span("@electric_sql"), screen_name: "electric_sql", name: "ElectricSQL", id_str: "2" }, - ], - urls: [ - { - indices: span("https://t.co/hz4sync"), - url: "https://t.co/hz4sync", - display_url: "hazel.sh/blog/sync", - expanded_url: "https://hazel.sh/blog/sync", - }, - ], - symbols: [], - }, - user: { - id_str: "1", - name: "Hazel", - screen_name: "hazelchat", - profile_image_url_https: asset("avatars/hazelchat-96x96.png"), - verified: false, - is_blue_verified: true, - profile_image_shape: "Circle", - }, - photos: [ - { url: asset("tweets/sync-diagram-1200x675.png"), width: 1200, height: 675, expandedUrl: "" }, - { url: asset("tweets/sync-latency-1200x675.png"), width: 1200, height: 675, expandedUrl: "" }, - ], - favorite_count: 1280, - conversation_count: 42, - reply_count: 42, - retweet_count: 0, - } -} - -// MARK: AI replies (Rivet message actor) - -const idleState = { - status: "idle", - data: {}, - text: "", - isStreaming: false, - progress: null, - error: null, - startedAt: null, - completedAt: null, - steps: [], - currentStepIndex: null, -} - -const answer = - "Here is what shipped today:\n\n- **api** v2.4 with the new `presence` endpoint\n- **web** rolled back to `7f3c2a1` after the login regression\n\nNothing is blocking the launch." - -/** Token-sized chunks of `text`, each event carrying the text so far (`textChunk`'s `fullText`). */ -const chunks = (text: string, size: number) => { - const events = [] - for (let end = size; end < text.length + size; end += size) { - const fullText = text.slice(0, Math.min(end, text.length)) - events.push({ name: "textChunk", payload: { chunk: fullText.slice(end - size), fullText } }) - } - return events -} - -const streamedEvents = chunks(answer, 24) - -const actors: Record = { - [messageId("ai:completed")]: { - state: idleState, - events: [ - { name: "started", payload: { data: {} } }, - ...streamedEvents, - { name: "streamEnd", payload: { text: answer } }, - { name: "completed", payload: { data: {} } }, - ], - }, - [messageId("ai:streaming")]: { - state: idleState, - events: [ - { name: "started", payload: { data: {} } }, - { name: "progress", payload: { progress: 40 } }, - ...streamedEvents.slice(0, Math.ceil(streamedEvents.length / 2)), - ], - }, - [messageId("ai:thinking")]: { - state: idleState, - events: [{ name: "started", payload: { data: {} } }], - }, -} - -const live = (extra: Record = {}) => [{ liveState: { enabled: true, ...extra } }] - -// MARK: Messages - -const messages: Row[] = [ - message({ - key: "unfurl:article", - channel: "unfurls", - author: "grace", - time: "10:00", - content: `Great write-up on the sync engine ${syncArticle}`, - }), - message({ - key: "unfurl:plain", - channel: "unfurls", - author: "alan", - time: "10:20", - content: plainArticle, - }), - message({ - key: "unfurl:missing", - channel: "unfurls", - author: "margaret", - time: "10:40", - content: "This one has no preview: https://unfurl.example.net/nothing-here", - }), - message({ - key: "tweet:launch", - channel: "tweets", - author: "linus", - time: "11:00", - content: `https://x.com/hazelchat/status/${tweetId}`, - }), - message({ - key: "tweet:missing", - channel: "tweets", - author: "katherine", - time: "11:30", - content: `Deleted already? https://twitter.com/someone/status/${missingTweetId}`, - }), - message({ - key: "video:talk", - channel: "videos", - author: "margaret", - time: "12:00", - content: "The sync talk from last week https://www.youtube.com/watch?v=sYnCtAlk01&t=1m30s", - }), - message({ - key: "video:short", - channel: "videos", - author: "alan", - time: "12:10", - content: "https://youtu.be/sHoRtClip9", - }), - message({ - key: "gif:giphy", - channel: "gifs", - author: "linus", - time: "13:00", - content: "https://media.giphy.com/media/shipit42/giphy.gif", - }), - message({ - key: "gif:klipy", - channel: "gifs", - author: "katherine", - time: "13:05", - content: "https://static.klipy.com/ii/parity/celebrate-320x240.gif", - }), - message({ - key: "ai:question", - channel: "ai-replies", - author: "ada", - time: "14:00", - content: "What shipped today?", - }), - message({ key: "ai:completed", channel: "ai-replies", author: "assistant", time: "14:01", content: "", embeds: live() }), - message({ - key: "ai:cached", - channel: "ai-replies", - author: "assistant", - time: "14:20", - content: "", - embeds: live({ cached: { status: "completed", data: {}, text: "Cached answer: the deploy finished at **14:12**." } }), - }), - message({ - key: "ai:failed", - channel: "ai-replies", - author: "assistant", - time: "14:40", - content: "", - embeds: live({ cached: { status: "failed", data: {}, error: "The model provider timed out." } }), - }), - message({ - key: "ai:streaming", - channel: "ai-live", - author: "assistant", - time: "14:50", - content: "", - embeds: live(), - }), - message({ - key: "ai:thinking", - channel: "ai-live", - author: "assistant", - time: "14:55", - content: "", - embeds: live({ loading: { text: "Analyzing deploys", icon: "brain" } }), - }), -] - -// MARK: Workspace rows - -const channelNames: Record = { - unfurls: "unfurls", - tweets: "tweets", - videos: "videos", - gifs: "gifs", - "ai-replies": "ai-replies", - "ai-live": "ai-live", -} - -const channels: Row[] = embedChannelKeys.map((key) => ({ - id: channelId(key), - name: channelNames[key], - icon: null, - type: "public", - organizationId: orgId, - parentChannelId: null, - sectionId: null, - createdAt: at(-30, "12:00"), - updatedAt: null, - deletedAt: null, -})) - -const members: ReadonlyArray = ["ada", "grace", "alan", "margaret", "linus", "katherine"] -const channelMembers: Row[] = embedChannelKeys.flatMap((channel) => - members.map((person) => ({ - id: stableId(`embeds:channel-member:${channel}:${person}`), - channelId: channelId(channel), - userId: richUserId(person), - isHidden: false, - isMuted: false, - isFavorite: false, - lastSeenMessageId: null, - notificationCount: 0, - joinedAt: at(-30, "12:00"), - createdAt: at(-30, "12:00"), - deletedAt: null, - })), -) - -const assistant: Row = { - id: assistantId, - externalId: "bot_assistant", - email: "assistant@bots.hazel.test", - firstName: "Hazel AI", - lastName: "", - avatarUrl: asset("avatars/assistant-128x128.png"), - userType: "machine", - settings: null, - isOnboarded: true, - timezone: null, - createdAt: at(-60, "12:00"), - updatedAt: null, - deletedAt: null, -} - -const assistantBot: Row = { - id: stableId("embeds:bot:assistant"), - userId: assistantId, - createdBy: richUserId("ada"), - name: "Hazel AI", - description: "Answers questions about your workspace", - webhookUrl: null, - apiTokenHash: "fixture-token-hash-assistant", - scopes: null, - metadata: null, - isPublic: false, - installCount: 1, - allowedIntegrations: null, - mentionable: false, - createdAt: at(-60, "12:00"), - updatedAt: null, - deletedAt: null, -} - -const base = richDataset.tables - -export const embedsDataset: Dataset = { - ...richDataset, - name: "embeds", - tables: { - ...base, - users: [...base.users!, assistant], - bots: [...base.bots!, assistantBot], - channels: [...base.channels!, ...channels], - channel_members: [...base.channel_members!, ...channelMembers], - messages: [...base.messages!, ...messages], - }, - network: { linkPreviews, tweets: { [tweetId]: buildTweet() } }, - actors, -} - -/** Scenario paths into the embeds channels. */ -export const embedsChat = (key: EmbedChannelKey) => `/hazel/chat/${channelId(key)}` diff --git a/packages/ui-parity/src/fixtures/datasets/empty.ts b/packages/ui-parity/src/fixtures/datasets/empty.ts deleted file mode 100644 index bcef09046..000000000 --- a/packages/ui-parity/src/fixtures/datasets/empty.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" - -/** - * A brand-new organization: one onboarded owner, no channels, no messages, no teammates. - * Covers the org home, chat index and sidebar empty states. - */ - -const now = new Date("2026-03-12T15:00:00.000Z") -const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000) - -const orgId = stableId("org:nimbus") -const userId = stableId("user:robin") - -const users: Row[] = [ - { - id: userId, - externalId: "user_robin", - email: "robin@nimbus.test", - firstName: "Robin", - lastName: "Founder", - avatarUrl: null, - userType: "user", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: minutesAgo(30), - updatedAt: null, - deletedAt: null, - }, -] - -const organizations: Row[] = [ - { - id: orgId, - name: "Nimbus", - slug: "nimbus", - logoUrl: null, - settings: null, - isPublic: false, - createdAt: minutesAgo(20), - updatedAt: null, - deletedAt: null, - }, -] - -const organizationMembers: Row[] = [ - { - id: stableId("org-member:nimbus:robin"), - organizationId: orgId, - userId, - role: "owner", - nickname: null, - joinedAt: minutesAgo(20), - invitedBy: null, - deletedAt: null, - createdAt: minutesAgo(20), - }, -] - -const presence: Row[] = [ - { - id: stableId("presence:robin"), - userId, - status: "online", - customMessage: null, - statusEmoji: null, - statusExpiresAt: null, - activeChannelId: null, - suppressNotifications: false, - updatedAt: minutesAgo(1), - lastSeenAt: minutesAgo(1), - }, -] - -export const emptyDataset: Dataset = { - name: "empty", - now, - clerkOrgId: "org_nimbus", - currentUser: { - id: userId, - clerkUserId: "user_robin", - organizationId: orgId, - role: "owner", - firstName: "Robin", - lastName: "Founder", - email: "robin@nimbus.test", - isOnboarded: true, - timezone: "UTC", - settings: null, - } as Dataset["currentUser"], - tables: { - users, - organizations, - organization_members: organizationMembers, - user_presence_status: presence, - }, - rpc: {}, -} - -export const emptyIds = { orgSlug: "nimbus", orgId } diff --git a/packages/ui-parity/src/fixtures/datasets/entry.ts b/packages/ui-parity/src/fixtures/datasets/entry.ts deleted file mode 100644 index 0a2822680..000000000 --- a/packages/ui-parity/src/fixtures/datasets/entry.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PublicOrganizationInfo } from "@hazel/domain/rpc" -import type { OrganizationId } from "@hazel/schema" -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" - -/** Variants of the default workspace for the entry screens (sign-in, join, select organization). */ - -const now = defaultDataset.now -const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000) - -/** An anonymous visitor. Tables are the default workspace's, but nothing authenticated loads. */ -export const signedOutDataset: Dataset = { ...defaultDataset, name: "signed-out", signedOut: true } - -/** Ada belongs to three organizations, one still without a slug (setup unfinished). */ -const extraOrgs = [ - { key: "acme", name: "Acme Robotics", slug: "acme", role: "member", joinedDaysAgo: 30 }, - { key: "orbit", name: "Orbit Studio", slug: null, role: "admin", joinedDaysAgo: 2 }, -] as const - -const organizations: Row[] = extraOrgs.map((org) => ({ - id: stableId(`org:${org.key}`), - name: org.name, - slug: org.slug, - logoUrl: null, - settings: null, - isPublic: org.key === "acme", - createdAt: daysAgo(org.joinedDaysAgo + 10), - updatedAt: null, - deletedAt: null, -})) - -const organizationMembers: Row[] = extraOrgs.map((org) => ({ - id: stableId(`org-member:${org.key}:ada`), - organizationId: stableId(`org:${org.key}`), - userId: defaultIds.user("ada"), - role: org.role, - nickname: null, - joinedAt: daysAgo(org.joinedDaysAgo), - invitedBy: null, - deletedAt: null, - createdAt: daysAgo(org.joinedDaysAgo), -})) - -export const multiOrgDataset: Dataset = { - ...defaultDataset, - name: "multi-org", - tables: { - ...defaultDataset.tables, - organizations: [...(defaultDataset.tables.organizations ?? []), ...organizations], - organization_members: [...(defaultDataset.tables.organization_members ?? []), ...organizationMembers], - }, -} - -/** The organization behind `/join/acme`; every other slug is unknown or not public. */ -export const publicOrgSlug = "acme" - -export const publicOrganizationBySlug = (payload: unknown) => - (payload as { slug: string }).slug === publicOrgSlug - ? new PublicOrganizationInfo({ - id: stableId("org:acme"), - name: "Acme Robotics", - slug: publicOrgSlug, - logoUrl: null, - memberCount: 12, - }) - : null diff --git a/packages/ui-parity/src/fixtures/datasets/errors.ts b/packages/ui-parity/src/fixtures/datasets/errors.ts deleted file mode 100644 index 5e005a718..000000000 --- a/packages/ui-parity/src/fixtures/datasets/errors.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { InternalServerError } from "@hazel/domain" -import { - BotAlreadyInstalledError, - BotNotFoundError, - ChannelNotFoundError, - ConnectInviteInvalidStateError, - ConnectInviteNotFoundError, - CustomEmojiNotFoundError, -} from "@hazel/domain/rpc" -import { Effect } from "effect" -import type { Dataset } from "../dataset.ts" -import { integrationsDataset } from "./integrations.ts" - -/** - * The rich workspace where selected RPCs fail with their real typed errors, to capture - * error states and error toasts. Reads not listed here still succeed. - */ -type Payload = Record -const payloadOf = (payload: unknown) => payload as Payload - -export const errorsDataset: Dataset = { - ...integrationsDataset, - name: "errors", - rpc: { - ...integrationsDataset.rpc, - "chatSync.connection.list": () => - Effect.fail(new InternalServerError({ message: "Chat sync is unavailable" })), - "channelWebhook.list": (payload) => - Effect.fail(new ChannelNotFoundError({ channelId: payloadOf(payload).channelId as never })), - "channel.update": (payload) => - Effect.fail(new ChannelNotFoundError({ channelId: payloadOf(payload).id as never })), - "customEmoji.delete": (payload) => - Effect.fail(new CustomEmojiNotFoundError({ customEmojiId: payloadOf(payload).id as never })), - "connectShare.invite.accept": (payload) => - Effect.fail( - new ConnectInviteInvalidStateError({ - inviteId: payloadOf(payload).inviteId as never, - status: "expired", - message: "Invite expired", - }), - ), - "connectShare.invite.revoke": (payload) => - Effect.fail( - new ConnectInviteNotFoundError({ - inviteId: payloadOf(payload).inviteId as never, - message: "Invite not found", - }), - ), - "bot.install": (payload) => - Effect.fail(new BotAlreadyInstalledError({ botId: payloadOf(payload).botId as never })), - "bot.installById": (payload) => - Effect.fail(new BotNotFoundError({ botId: payloadOf(payload).botId as never })), - "bot.uninstall": (payload) => - Effect.fail(new BotNotFoundError({ botId: payloadOf(payload).botId as never })), - }, -} diff --git a/packages/ui-parity/src/fixtures/datasets/heavy-content.ts b/packages/ui-parity/src/fixtures/datasets/heavy-content.ts deleted file mode 100644 index a22c9711b..000000000 --- a/packages/ui-parity/src/fixtures/datasets/heavy-content.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Seeded text generation for the `heavy` dataset. Everything derives from a - * mulberry32 stream, so two imports produce identical rows. - */ - -export type Rng = () => number - -/** mulberry32: tiny, fast, good enough for fixture data. */ -export const mulberry32 = (seed: number): Rng => { - let state = seed >>> 0 - return () => { - 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 - } -} - -export const int = (rng: Rng, min: number, max: number) => min + Math.floor(rng() * (max - min + 1)) -export const pick = (rng: Rng, items: ReadonlyArray): A => items[Math.floor(rng() * items.length)]! - -const subjects = [ - "the sync worker", - "the release branch", - "our onboarding flow", - "the billing export", - "the search index", - "the mobile build", - "the staging cluster", - "the notification fan-out", - "the migration script", - "the design review", - "the parser", - "the cache layer", - "the audit log", - "the dashboard", - "the retry queue", - "the invite emails", -] - -const verbs = [ - "is looking much healthier since", - "started timing out right after", - "needs another pass before", - "should be ready ahead of", - "got noticeably faster after", - "still trips over edge cases from", - "was rolled back because of", - "looks fine to me except for", -] - -const objects = [ - "the Tuesday deploy", - "yesterday's config change", - "the schema bump", - "the new rate limits", - "the dependency upgrade", - "the load test", - "the holiday freeze", - "the customer call", - "the last sprint", - "the backfill", -] - -const shortLines = [ - "On it.", - "Thanks!", - "Agreed.", - "Looks good to me.", - "Can you share the logs?", - "Merged.", - "Nice work, ship it.", - "Give me ten minutes.", - "Back from lunch, catching up now.", - "Same here.", - "Not sure, let me check.", - "That makes sense.", - "Rebasing now.", - "Deploying to staging.", - "Fixed in the latest commit.", - "Who owns this one?", - "Done.", - "+1", - "Let's pair on it after standup.", - "Can we push this to tomorrow?", -] - -const codeTokens = [ - "retryBudget", - "SyncWorker", - "--force", - "main", - "bun run test", - "maxBatchSize", - "useChannel", - "v2.3.1", -] -const boldTokens = ["blocking", "before Friday", "rolled back", "needs review", "high priority", "done"] -const italicTokens = ["probably", "for now", "again", "eventually", "really", "temporarily"] - -const decorate = (rng: Rng, sentence: string) => { - const roll = rng() - if (roll < 0.1) return `${sentence} See \`${pick(rng, codeTokens)}\`.` - if (roll < 0.18) return `${sentence} This is **${pick(rng, boldTokens)}**.` - if (roll < 0.25) return `${sentence.slice(0, -1)}, *${pick(rng, italicTokens)}*.` - return sentence -} - -const sentence = (rng: Rng) => { - const text = `${pick(rng, subjects)} ${pick(rng, verbs)} ${pick(rng, objects)}.` - return decorate(rng, text.charAt(0).toUpperCase() + text.slice(1)) -} - -const sentences = (rng: Rng, count: number) => Array.from({ length: count }, () => sentence(rng)).join(" ") - -/** One message body. Mix of lengths so virtualized row heights vary. No URLs or mentions. */ -export const messageContent = (rng: Rng): string => { - const roll = rng() - if (roll < 0.4) return decorate(rng, pick(rng, shortLines)) - if (roll < 0.62) return sentences(rng, int(rng, 1, 2)) - if (roll < 0.78) return sentences(rng, int(rng, 3, 6)) - if (roll < 0.9) { - const items = Array.from({ length: int(rng, 2, 5) }, () => `- ${sentence(rng)}`) - return `${pick(rng, ["Quick update:", "Plan for today:", "Notes from the sync:", "Open items:"])}\n\n${items.join("\n")}` - } - return Array.from({ length: int(rng, 2, 4) }, () => sentences(rng, int(rng, 1, 2))).join("\n") -} - -const teams = [ - "platform", - "growth", - "mobile", - "web", - "infra", - "data", - "design", - "support", - "sales", - "security", - "billing", - "search", - "sync", - "api", - "ops", - "qa", - "docs", - "brand", - "people", - "finance", -] -const topics = [ - "general", - "standup", - "alerts", - "releases", - "incidents", - "planning", - "reviews", - "random", - "help", - "announcements", - "roadmap", - "experiments", - "metrics", - "feedback", - "hiring", - "oncall", - "deploys", - "bugs", - "research", - "retro", - "offsite", - "ideas", - "wins", - "triage", - "sandbox", -] -const longSuffixes = [ - "quarterly-planning-and-roadmap-alignment", - "cross-functional-working-group", - "customer-escalations-and-follow-ups", - "incident-retrospectives-archive", - "vendor-evaluation-and-procurement", -] - -/** Unique channel name for index `i` (0..499); every ninth one gets a 40-60 char tail. */ -export const channelName = (i: number) => { - const base = `${teams[i % teams.length]}-${topics[Math.floor(i / teams.length) % topics.length]}` - return i % 9 === 0 ? `${base}-${longSuffixes[i % longSuffixes.length]}` : base -} - -export const firstNames = [ - "Grace", - "Alan", - "Margaret", - "Linus", - "Barbara", - "Dennis", - "Frances", - "Ken", - "Radia", - "Edsger", - "Hedy", - "John", - "Katherine", - "Tim", - "Sophie", - "Niklaus", - "Shafi", - "Donald", - "Annie", - "Guido", -] -export const lastNames = [ - "Hopper", - "Turing", - "Hamilton", - "Torvalds", - "Liskov", - "Ritchie", - "Allen", - "Thompson", - "Perlman", - "Dijkstra", - "Lamarr", - "Backus", - "Johnson", - "Berners-Lee", - "Wilson", - "Wirth", - "Goldwasser", - "Knuth", -] -export const longPeople = [ - { firstName: "Maximiliana-Theodora", lastName: "Vandenberghe-Okonkwo" }, - { firstName: "Bartholomew", lastName: "Featherstonehaugh-Ramaswamy" }, - { firstName: "Anastasiya", lastName: "Wolfeschlegelsteinhausen" }, - { firstName: "Christopherson", lastName: "Montgomery-Abernathy III" }, - { firstName: "Guadalupe Esperanza", lastName: "de la Fuente-Castellanos" }, -] diff --git a/packages/ui-parity/src/fixtures/datasets/heavy.ts b/packages/ui-parity/src/fixtures/datasets/heavy.ts deleted file mode 100644 index 26e95f0e9..000000000 --- a/packages/ui-parity/src/fixtures/datasets/heavy.ts +++ /dev/null @@ -1,342 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { - channelName, - firstNames, - int, - lastNames, - longPeople, - messageContent, - mulberry32, - pick, - type Rng, -} from "./heavy-content.ts" - -/** - * A large workspace for list performance and virtualization parity: ~50 users, - * 500 channels Ada belongs to (a third unread), one 10,000-message #firehose and - * ~2,000 messages spread elsewhere. Same org and signed-in user as `default`. - */ - -const now = new Date("2026-03-12T15:00:00.000Z") -const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000) -const secondsAgo = (seconds: number) => new Date(now.getTime() - seconds * 1000) - -const orgId = stableId("org:hazel") - -interface Person { - readonly key: string - readonly firstName: string - readonly lastName: string - readonly role: "owner" | "admin" | "member" -} - -const people: ReadonlyArray = [ - { key: "ada", firstName: "Ada", lastName: "Lovelace", role: "owner" }, - ...Array.from( - { length: 42 }, - (_, i): Person => ({ - key: `u${i}`, - firstName: firstNames[i % firstNames.length]!, - lastName: lastNames[(i + Math.floor(i / firstNames.length) * 5) % lastNames.length]!, - role: i < 2 ? "admin" : "member", - }), - ), - ...longPeople.map((person, i): Person => ({ key: `long${i}`, ...person, role: "member" })), -] -const others = people.slice(1) - -const userId = (key: string) => stableId(`user:${key}`) - -const users: Row[] = people.map((person) => ({ - id: userId(person.key), - externalId: `user_${person.key}`, - email: `${person.key}@hazel.test`, - firstName: person.firstName, - lastName: person.lastName, - avatarUrl: null, - userType: "user", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: minutesAgo(60 * 24 * 90), - updatedAt: null, - deletedAt: null, -})) - -const organizations: Row[] = [ - { - id: orgId, - name: "Hazel Labs", - slug: "hazel", - logoUrl: null, - settings: null, - isPublic: false, - createdAt: minutesAgo(60 * 24 * 90), - updatedAt: null, - deletedAt: null, - }, -] - -const organizationMembers: Row[] = people.map((person) => ({ - id: stableId(`org-member:${person.key}`), - organizationId: orgId, - userId: userId(person.key), - role: person.role, - nickname: null, - joinedAt: minutesAgo(60 * 24 * 90), - invitedBy: null, - deletedAt: null, - createdAt: minutesAgo(60 * 24 * 90), -})) - -const sectionNames = [ - "Projects", - "Teams", - "Customers", - "Incidents and on-call rotations", - "Social", - "Archive", -] -const sectionId = (index: number) => stableId(`heavy-section:${index}`) -const channelSections: Row[] = sectionNames.map((name, order) => ({ - id: sectionId(order), - organizationId: orgId, - name, - order, - createdAt: minutesAgo(60 * 24 * 60), - updatedAt: null, - deletedAt: null, -})) - -interface ChannelDef { - readonly key: string - readonly name: string - readonly type: "public" | "private" | "single" - readonly sectionId: string | null - /** Members besides Ada. */ - readonly members: ReadonlyArray -} - -const CHANNEL_COUNT = 500 -const DM_COUNT = 8 -const rng: Rng = mulberry32(0x6ea7) - -const sampleOthers = (count: number) => { - const chosen = new Set() - while (chosen.size < count) chosen.add(pick(rng, others).key) - return [...chosen] -} - -const channelDefs: ReadonlyArray = [ - { key: "firehose", name: "firehose", type: "public", sectionId: null, members: others.map((p) => p.key) }, - { key: "general", name: "general", type: "public", sectionId: null, members: others.map((p) => p.key) }, - ...Array.from({ length: DM_COUNT }, (_, i): ChannelDef => { - const other = others[(i * 5) % others.length]! - return { - key: `dm-${other.key}`, - name: `${other.firstName} ${other.lastName}`, - type: "single", - sectionId: null, - members: [other.key], - } - }), - ...Array.from({ length: CHANNEL_COUNT - 2 - DM_COUNT }, (_, i): ChannelDef => { - const roll = rng() - return { - key: `c${i}`, - name: channelName(i + 1), - type: i % 11 === 3 ? "private" : "public", - sectionId: roll < 0.4 ? null : sectionId(Math.floor((roll - 0.4) / 0.1)), - members: sampleOthers(int(rng, 1, 4)), - } - }), -] - -const channelId = (key: string) => stableId(`heavy-channel:${key}`) - -const channels: Row[] = channelDefs.map((channel) => ({ - id: channelId(channel.key), - name: channel.name, - icon: null, - type: channel.type, - organizationId: orgId, - parentChannelId: null, - sectionId: channel.sectionId, - createdAt: minutesAgo(60 * 24 * 60), - updatedAt: null, - deletedAt: null, -})) - -const membership = ( - channelKey: string, - personKey: string, - flags: { isMuted?: boolean; isFavorite?: boolean; notificationCount?: number } = {}, -): Row => ({ - id: stableId(`heavy-channel-member:${channelKey}:${personKey}`), - channelId: channelId(channelKey), - userId: userId(personKey), - isHidden: false, - isMuted: flags.isMuted ?? false, - isFavorite: flags.isFavorite ?? false, - lastSeenMessageId: null, - notificationCount: flags.notificationCount ?? 0, - joinedAt: minutesAgo(60 * 24 * 60), - createdAt: minutesAgo(60 * 24 * 60), - deletedAt: null, -}) - -/** Ada's flags per channel: about a third unread, every 61st a favorite, every 47th muted. */ -const adaFlags = (channel: ChannelDef, index: number) => { - const unread = channel.key !== "firehose" && rng() < 1 / 3 - return { - notificationCount: unread ? (rng() < 0.1 ? int(rng, 100, 250) : int(rng, 1, 30)) : 0, - isFavorite: index % 61 === 5, - isMuted: index % 47 === 7, - } -} - -const channelMembers: Row[] = channelDefs.flatMap((channel, index) => [ - membership(channel.key, "ada", adaFlags(channel, index)), - ...channel.members.map((member) => membership(channel.key, member)), -]) - -const messageRng: Rng = mulberry32(0xf12e) -const BIG_CHANNEL_MESSAGES = 10_000 -const authorPool = [...people.map((p) => p.key), "ada", "ada", "ada"] - -/** - * Authors in runs of 1-6 and seconds-before-now for each message, oldest first. - * Runs are seconds apart (grouped); between runs the gap is up to 7 minutes. - */ -const timeline = (count: number, latestAgo: number) => { - const authors: string[] = [] - while (authors.length < count) { - let author = pick(messageRng, authorPool) - while (author === authors.at(-1)) author = pick(messageRng, authorPool) - const run = Math.min(int(messageRng, 1, 6), count - authors.length) - for (let i = 0; i < run; i++) authors.push(author) - } - const ago: number[] = Array.from({ length: count }, () => latestAgo) - for (let i = count - 2; i >= 0; i--) { - const gap = authors[i] === authors[i + 1] ? int(messageRng, 5, 60) : int(messageRng, 20, 420) - ago[i] = ago[i + 1]! + gap - } - return authors.map((author, i) => ({ author, ago: ago[i]! })) -} - -const messageId = (channelKey: string, index: number) => stableId(`heavy-message:${channelKey}:${index}`) - -const channelMessages = (channelKey: string, count: number, latestAgo: number): Row[] => - timeline(count, latestAgo).map(({ author, ago }, index) => ({ - id: messageId(channelKey, index), - channelId: channelId(channelKey), - conversationId: null, - authorId: userId(author), - content: messageContent(messageRng), - embeds: null, - replyToMessageId: null, - threadChannelId: null, - createdAt: secondsAgo(ago), - updatedAt: null, - deletedAt: null, - })) - -const firehoseMessages = channelMessages("firehose", BIG_CHANNEL_MESSAGES, 90) - -/** ~2,000 more: #general, every DM, and the first 190 generated channels. */ -const spreadMessages: Row[] = [ - ...channelMessages("general", 60, 240), - ...channelDefs - .filter((channel) => channel.type === "single") - .flatMap((channel) => channelMessages(channel.key, 6, int(messageRng, 600, 60 * 60 * 24))), - ...Array.from({ length: 190 }, (_, i) => - channelMessages(`c${i}`, int(messageRng, 4, 16), int(messageRng, 300, 60 * 60 * 24 * 7)), - ).flat(), -] - -const emojis = ["👍", "🎉", "🙏", "❤️", "😂"] - -/** - * Reactions on ~7% of #firehose messages, 1-3 emoji each, Ada among the reactors on some. - * A few of the newest always react so the steady-state viewport shows reaction pills. - */ -const messageReactions: Row[] = firehoseMessages.flatMap((message, index) => { - const forced = index >= BIG_CHANNEL_MESSAGES - 8 && index % 3 === 0 - if (!forced && messageRng() >= 0.07) return [] - const createdAt = new Date(Math.min((message.createdAt as Date).getTime() + 30_000, now.getTime())) - const chosenEmojis = new Set( - Array.from({ length: int(messageRng, 1, 3) }, () => pick(messageRng, emojis)), - ) - return [...chosenEmojis].flatMap((emoji) => { - const reactors = new Set( - Array.from({ length: int(messageRng, 1, 4) }, () => pick(messageRng, authorPool)), - ) - return [...reactors].map((person) => ({ - id: stableId(`heavy-reaction:${index}:${person}:${emoji}`), - messageId: message.id, - channelId: channelId("firehose"), - conversationId: null, - userId: userId(person), - emoji, - createdAt, - })) - }) -}) - -const statuses = ["online", "online", "away", "busy", "dnd", "offline", "offline"] as const -const presence: Row[] = people.map((person, index) => ({ - id: stableId(`presence:${person.key}`), - userId: userId(person.key), - status: index === 0 ? "online" : pick(messageRng, statuses), - customMessage: null, - statusEmoji: null, - statusExpiresAt: null, - activeChannelId: null, - suppressNotifications: false, - updatedAt: minutesAgo(5), - lastSeenAt: minutesAgo(5), -})) - -const ada = people[0]! - -export const heavyDataset: Dataset = { - name: "heavy", - now, - clerkOrgId: "org_hazel", - currentUser: { - id: userId("ada"), - clerkUserId: "user_ada", - organizationId: orgId, - role: "owner", - firstName: ada.firstName, - lastName: ada.lastName, - email: "ada@hazel.test", - isOnboarded: true, - timezone: "UTC", - settings: null, - } as Dataset["currentUser"], - tables: { - users, - organizations, - organization_members: organizationMembers, - channel_sections: channelSections, - channels, - channel_members: channelMembers, - messages: [...firehoseMessages, ...spreadMessages], - message_reactions: messageReactions, - user_presence_status: presence, - }, - rpc: {}, -} - -/** Handy references for scenarios. */ -export const heavyIds = { - orgSlug: "hazel", - orgId, - channel: channelId, - user: userId, - currentUserId: userId("ada"), - bigChannelId: channelId("firehose"), - smallChannelId: channelId("general"), -} diff --git a/packages/ui-parity/src/fixtures/datasets/inbox.ts b/packages/ui-parity/src/fixtures/datasets/inbox.ts deleted file mode 100644 index d83b90027..000000000 --- a/packages/ui-parity/src/fixtures/datasets/inbox.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" - -/** - * The default workspace plus a full notifications inbox for Ada: channel, DM and thread - * notifications spread over today, yesterday, this week and older, read and unread mixed. - * Also links Ada's Discord account for the linked-accounts screen. - */ - -const now = defaultDataset.now -const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000) -const hoursAgo = (hours: number) => minutesAgo(hours * 60) -const daysAgo = (days: number) => minutesAgo(days * 60 * 24) - -type Person = Parameters[0] - -const threadChannelId = stableId("channel:thread-onboarding") -const dmChannelId = defaultIds.channel("dm-grace") -const general = defaultIds.channel("general") - -const threadChannel: Row = { - id: threadChannelId, - name: "Onboarding copy feedback", - icon: null, - type: "thread", - organizationId: defaultIds.orgId, - parentChannelId: general, - sectionId: null, - createdAt: hoursAgo(2), - updatedAt: null, - deletedAt: null, -} - -const threadMembers: Row[] = (["ada", "alan", "margaret"] as const).map((person) => ({ - id: stableId(`channel-member:thread-onboarding:${person}`), - channelId: threadChannelId, - userId: defaultIds.user(person), - isHidden: false, - isMuted: false, - isFavorite: false, - lastSeenMessageId: null, - notificationCount: 0, - joinedAt: hoursAgo(2), - createdAt: hoursAgo(2), - deletedAt: null, -})) - -const extraMessages: ReadonlyArray = [ - ["dm:0", dmChannelId, "grace", minutesAgo(25), "Do you have ten minutes before the planning call?"], - ["dm:1", dmChannelId, "grace", hoursAgo(26), "Sent you the hiring rubric, let me know what you think."], - ["dm:2", dmChannelId, "grace", daysAgo(3), "Thanks for covering the on-call shift!"], - [ - "thread:0", - threadChannelId, - "alan", - minutesAgo(50), - "Could we shorten the second paragraph a bit more?", - ], - [ - "thread:1", - threadChannelId, - "margaret", - hoursAgo(28), - "I left a few suggestions inline. The invite step wording is **much** clearer than before, nice work on that one.", - ], - [ - "design:0", - defaultIds.channel("design"), - "linus", - daysAgo(2), - "New icon set is uploaded to the shared drive.", - ], - [ - "engineering:0", - defaultIds.channel("engineering"), - "margaret", - daysAgo(9), - "Postmortem for last week's sync outage is published, please add your notes before Friday so we can close it out.", - ], -] - -const messages: Row[] = extraMessages.map(([key, channelId, author, createdAt, content]) => ({ - id: stableId(`message:${key}`), - channelId, - conversationId: null, - authorId: defaultIds.user(author), - content, - embeds: null, - replyToMessageId: null, - threadChannelId: null, - createdAt, - updatedAt: null, - deletedAt: null, -})) - -/** [key, message id (or null), targeted channel (or null), createdAt, read] */ -const notificationDefs: ReadonlyArray = [ - ["n0", stableId("message:general:10"), general, minutesAgo(3), false], - ["n1", stableId("message:dm:0"), dmChannelId, minutesAgo(25), false], - ["n2", stableId("message:general:8"), general, minutesAgo(42), true], - ["n3", stableId("message:thread:0"), threadChannelId, minutesAgo(50), false], - ["n4", stableId("message:general:7"), general, minutesAgo(60), true], - ["n5", stableId("message:general:3"), general, minutesAgo(168), true], - ["n6", stableId("message:dm:1"), dmChannelId, hoursAgo(26), true], - ["n7", stableId("message:thread:1"), threadChannelId, hoursAgo(28), false], - ["n8", null, defaultIds.channel("design"), hoursAgo(30), true], - ["n9", stableId("message:design:0"), defaultIds.channel("design"), daysAgo(2), false], - ["n10", stableId("message:dm:2"), dmChannelId, daysAgo(3), true], - ["n11", null, null, daysAgo(3), false], - ["n12", stableId("message:engineering:0"), defaultIds.channel("engineering"), daysAgo(9), true], - ["n13", stableId("message:general:0"), general, daysAgo(12), true], -] - -const adaMemberId = stableId("org-member:ada") - -const notifications: Row[] = notificationDefs.map(([key, messageId, channelId, createdAt, read]) => ({ - id: stableId(`notification:${key}`), - memberId: adaMemberId, - targetedResourceId: channelId, - targetedResourceType: channelId ? "channel" : null, - resourceId: messageId, - resourceType: messageId ? "message" : null, - createdAt, - readAt: read ? new Date(createdAt.getTime() + 5 * 60_000) : null, -})) - -const discordConnection: Row = { - id: stableId("integration-connection:discord:ada"), - provider: "discord", - organizationId: defaultIds.orgId, - userId: defaultIds.user("ada"), - level: "user", - status: "active", - externalAccountId: "1029384756", - externalAccountName: "ada.lovelace", - connectedBy: defaultIds.user("ada"), - settings: null, - metadata: null, - errorMessage: null, - lastUsedAt: null, - createdAt: daysAgo(20), - updatedAt: null, - deletedAt: null, -} - -const tables = defaultDataset.tables - -export const inboxDataset: Dataset = { - ...defaultDataset, - name: "inbox", - tables: { - ...tables, - channels: [...(tables.channels ?? []), threadChannel], - channel_members: [...(tables.channel_members ?? []), ...threadMembers], - messages: [...(tables.messages ?? []), ...messages], - notifications, - integration_connections: [discordConnection], - }, -} diff --git a/packages/ui-parity/src/fixtures/datasets/integrations-rpc.ts b/packages/ui-parity/src/fixtures/datasets/integrations-rpc.ts deleted file mode 100644 index 809ac99d6..000000000 --- a/packages/ui-parity/src/fixtures/datasets/integrations-rpc.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { ChatSyncChannelLink, ChatSyncConnection, ChannelWebhook, ConnectInvite } from "@hazel/domain/models" -import { - ChannelWebhookListResponse, - ChatSyncChannelLinkListResponse, - ChatSyncConnectionListResponse, - ConnectInviteListResponse, -} from "@hazel/domain/rpc" -import type { Dataset } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultIds } from "./default.ts" - -/** Canned RPC responses for the `integrations` dataset (chat sync, connect invites, webhooks). */ - -const { orgId, user, channel } = defaultIds - -export const chatSyncIds = { - community: stableId("sync-connection:community"), - friends: stableId("sync-connection:friends"), - legacy: stableId("sync-connection:legacy"), -} - -export const LONG_GUILD_NAME = "Open Source Friends and Contributors Community Server With A Long Name" - -export const connectionRows = (now: Date) => { - const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60_000) - return [ - { key: "community", name: "Hazel Community", status: "active", synced: daysAgo(0.1), error: null }, - { key: "friends", name: LONG_GUILD_NAME, status: "paused", synced: daysAgo(3), error: null }, - { - key: "legacy", - name: "Legacy Guild", - status: "error", - synced: null, - error: "Missing permissions: the bot can no longer read messages in this server.", - }, - ].map((row, index) => - ChatSyncConnection.Schema.make({ - id: chatSyncIds[row.key as keyof typeof chatSyncIds], - organizationId: orgId, - integrationConnectionId: null, - provider: "discord", - externalWorkspaceId: `91827364550012${index}`, - externalWorkspaceName: row.name, - status: row.status, - settings: null, - metadata: null, - errorMessage: row.error, - lastSyncedAt: row.synced, - createdBy: user("ada"), - createdAt: daysAgo(30), - updatedAt: null, - deletedAt: null, - } as never), - ) -} - -export const channelLinkRows = (now: Date) => - [ - { key: "general", hazel: channel("general"), name: "general", direction: "both", active: true, webhook: "allowed" }, - { key: "design", hazel: channel("design"), name: "design-feedback", direction: "hazel_to_external", active: false, webhook: "denied" }, - { key: "eng", hazel: channel("engineering"), name: "engineering-announcements-and-release-notes", direction: "external_to_hazel", active: true, webhook: null }, - ].map((link, index) => - ChatSyncChannelLink.Schema.make({ - id: stableId(`sync-link:${link.key}`), - syncConnectionId: chatSyncIds.community, - hazelChannelId: link.hazel, - externalChannelId: `55500011122233${index}`, - externalChannelName: link.name, - direction: link.direction, - isActive: link.active, - settings: link.webhook ? { webhookPermission: { status: link.webhook } } : null, - lastSyncedAt: now, - createdAt: now, - updatedAt: null, - deletedAt: null, - } as never), - ) - -const invite = ( - now: Date, - key: string, - fields: { host: string; status: string; days: number; target?: string; channelKey?: "design" | "general" }, -) => - ConnectInvite.Schema.make({ - id: stableId(`connect-invite:${key}`), - conversationId: stableId(`connect-conversation:${key}`), - hostOrganizationId: fields.host, - hostChannelId: fields.channelKey ? channel(fields.channelKey) : stableId(`channel:${key}:host`), - targetKind: "slug", - targetValue: fields.target ?? "hazel", - guestOrganizationId: null, - status: fields.status, - allowGuestMemberAdds: false, - invitedBy: user("ada"), - acceptedBy: null, - acceptedAt: null, - expiresAt: null, - createdAt: new Date(now.getTime() - fields.days * 24 * 60 * 60_000), - updatedAt: null, - deletedAt: null, - } as never) - -const webhookRows = (now: Date) => - [ - { key: "ci", name: "CI notifications", enabled: true, suffix: "a1b2", used: 0.02 }, - { key: "alerts", name: "Grafana alerts", enabled: false, suffix: "9f3c", used: null }, - { key: "openstatus", name: "OpenStatus", enabled: true, suffix: "77de", used: 1 }, - ].map((hook) => - ChannelWebhook.Schema.make({ - id: stableId(`webhook:${hook.key}`), - channelId: channel("general"), - organizationId: orgId, - botUserId: stableId(`user:webhook:${hook.key}`), - name: hook.name, - description: null, - avatarUrl: null, - tokenSuffix: hook.suffix, - isEnabled: hook.enabled, - createdBy: user("ada"), - lastUsedAt: hook.used === null ? null : new Date(now.getTime() - hook.used * 24 * 60 * 60_000), - createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60_000), - updatedAt: null, - deletedAt: null, - } as never), - ) - -export const integrationsRpc = (now: Date): Dataset["rpc"] => ({ - "chatSync.connection.list": () => new ChatSyncConnectionListResponse({ data: connectionRows(now) }), - "chatSync.channelLink.list": (payload) => - new ChatSyncChannelLinkListResponse({ - data: - (payload as { syncConnectionId: string }).syncConnectionId === chatSyncIds.community - ? channelLinkRows(now) - : [], - }), - "connectShare.invite.listIncoming": () => - new ConnectInviteListResponse({ - data: [ - invite(now, "acme-ops", { host: stableId("org:acme"), status: "pending", days: 1 }), - invite(now, "globex-launch", { host: stableId("org:globex"), status: "pending", days: 4 }), - invite(now, "initech-tps", { host: stableId("org:initech"), status: "declined", days: 21 }), - ], - }), - "connectShare.invite.listOutgoing": () => - new ConnectInviteListResponse({ - data: [ - invite(now, "design-globex", { host: orgId, status: "pending", days: 2, target: "globex", channelKey: "design" }), - invite(now, "design-initech", { host: orgId, status: "declined", days: 15, target: "initech", channelKey: "design" }), - ], - }), - "channelWebhook.list": (payload) => - new ChannelWebhookListResponse({ - data: (payload as { channelId: string }).channelId === channel("general") ? webhookRows(now) : [], - }), -}) diff --git a/packages/ui-parity/src/fixtures/datasets/integrations.ts b/packages/ui-parity/src/fixtures/datasets/integrations.ts deleted file mode 100644 index 5678a23b9..000000000 --- a/packages/ui-parity/src/fixtures/datasets/integrations.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" -import { integrationsRpc } from "./integrations-rpc.ts" - -/** - * The default workspace plus rich settings data: custom emojis, an active Linear connection, - * bots (created, installed, public), connected organizations, chat sync and webhooks. - * Kept out of `default` so existing captures never change. - */ - -const now = defaultDataset.now -const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60_000) -const { orgId, user } = defaultIds - -/** 64 characters, the maximum the emoji name field accepts. */ -export const LONG_EMOJI_NAME = "extremely_long_custom_emoji_name_that_fills_every_allowed_slot64" -export const LONG_CHANNEL_NAME = - "product-roadmap-and-quarterly-planning-for-the-entire-organization-across-every-team-2026" -export const LONG_BOT_NAME = "Release Train Coordinator With An Unusually Long Application Name" - -const emojiSvg = (fill: string, glyph: string) => - `data:image/svg+xml;utf8,${encodeURIComponent( - `${glyph}`, - )}` - -const customEmojis: Row[] = [ - { key: "party_parrot", by: "ada", days: 2, fill: "#16a34a", glyph: "P" }, - { key: "shipit", by: "grace", days: 9, fill: "#2563eb", glyph: "S" }, - { key: LONG_EMOJI_NAME, by: "ada", days: 40, fill: "#9333ea", glyph: "L" }, -].map((emoji) => ({ - id: stableId(`emoji:${emoji.key}`), - organizationId: orgId, - name: emoji.key, - imageUrl: emojiSvg(emoji.fill, emoji.glyph), - createdBy: user(emoji.by as "ada" | "grace"), - createdAt: daysAgo(emoji.days), - updatedAt: null, - deletedAt: null, -})) - -const integrationConnections: Row[] = [ - { - id: stableId("integration:linear"), - provider: "linear", - organizationId: orgId, - userId: null, - level: "organization", - status: "active", - externalAccountId: "lin_hazel", - externalAccountName: "Hazel Labs workspace", - connectedBy: user("ada"), - settings: null, - metadata: null, - errorMessage: null, - lastUsedAt: daysAgo(1), - createdAt: daysAgo(30), - updatedAt: null, - deletedAt: null, - }, -] - -const botDefs = [ - { - key: "deploy", - name: "Deploy Bot", - description: "Posts deploy status and rollbacks to the channels you choose.", - by: "ada", - isPublic: true, - installCount: 1284, - scopes: ["messages:read", "messages:write", "channels:read"], - }, - { - key: "standup", - name: "Standup Helper", - description: null, - by: "ada", - isPublic: false, - installCount: 0, - scopes: [], - }, - { - key: "release", - name: LONG_BOT_NAME, - description: - "Coordinates release trains across every team, collects sign-offs, posts the changelog and reminds owners about blockers until the train leaves the station.", - by: "ada", - isPublic: true, - installCount: 37, - scopes: ["messages:write"], - }, - { - key: "triage", - name: "Triage Assistant", - description: "Labels incoming bug reports and routes them to the right team.", - by: "grace", - isPublic: true, - installCount: 512, - scopes: ["messages:read", "channels:read"], - }, -] as const - -const botUserId = (key: string) => stableId(`user:bot:${key}`) - -const botUsers: Row[] = botDefs.map((bot) => ({ - id: botUserId(bot.key), - externalId: `bot_${bot.key}`, - email: `${bot.key}@bots.hazel.test`, - firstName: bot.name, - lastName: "", - avatarUrl: null, - userType: "machine", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: daysAgo(20), - updatedAt: null, - deletedAt: null, -})) - -const bots: Row[] = botDefs.map((bot, index) => ({ - id: stableId(`bot:${bot.key}`), - userId: botUserId(bot.key), - createdBy: user(bot.by), - name: bot.name, - description: bot.description, - webhookUrl: null, - apiTokenHash: "parity-hash", - scopes: [...bot.scopes], - metadata: null, - isPublic: bot.isPublic, - installCount: bot.installCount, - allowedIntegrations: null, - mentionable: true, - createdAt: daysAgo(20 - index), - updatedAt: null, - deletedAt: null, -})) - -const botInstallations: Row[] = (["deploy", "triage"] as const).map((key) => ({ - id: stableId(`bot-installation:${key}`), - botId: stableId(`bot:${key}`), - organizationId: orgId, - installedBy: user("ada"), - installedAt: daysAgo(5), -})) - -/** Other workspaces that appear as connect partners and invite senders. */ -export const partnerOrgs = [ - { key: "acme", name: "Acme Corporation", slug: "acme" }, - { key: "globex", name: "Globex", slug: "globex" }, - { key: "initech", name: "Initech", slug: "initech" }, -] as const -export const partnerOrgId = (key: (typeof partnerOrgs)[number]["key"]) => stableId(`org:${key}`) - -const organizations: Row[] = [ - ...(defaultDataset.tables.organizations ?? []), - ...partnerOrgs.map((org) => ({ - id: partnerOrgId(org.key), - name: org.name, - slug: org.slug, - logoUrl: null, - settings: null, - isPublic: false, - createdAt: daysAgo(200), - updatedAt: null, - deletedAt: null, - })), -] - -/** #design is shared with Acme. */ -const sharedConversationId = stableId("connect-conversation:design-acme") -const connectConversationChannels: Row[] = [ - { key: "hazel", organizationId: orgId, channelId: defaultIds.channel("design"), role: "host" }, - { key: "acme", organizationId: partnerOrgId("acme"), channelId: stableId("channel:acme:design"), role: "guest" }, -].map((mount) => ({ - id: stableId(`connect-mount:${mount.key}`), - conversationId: sharedConversationId, - organizationId: mount.organizationId, - channelId: mount.channelId, - role: mount.role, - allowGuestMemberAdds: false, - isActive: true, - createdAt: daysAgo(12), - updatedAt: null, - deletedAt: null, -})) - -export const longChannelId = stableId("channel:long-name") -const channels: Row[] = [ - ...(defaultDataset.tables.channels ?? []), - { - id: longChannelId, - name: LONG_CHANNEL_NAME, - icon: null, - type: "public", - organizationId: orgId, - parentChannelId: null, - sectionId: null, - createdAt: daysAgo(10), - updatedAt: null, - deletedAt: null, - }, -] - -export const integrationsTables: Dataset["tables"] = { - ...defaultDataset.tables, - users: [...(defaultDataset.tables.users ?? []), ...botUsers], - organizations, - channels, - custom_emojis: customEmojis, - integration_connections: integrationConnections, - bots, - bot_installations: botInstallations, - connect_conversation_channels: connectConversationChannels, -} - -export const integrationsDataset: Dataset = { - ...defaultDataset, - name: "integrations", - tables: integrationsTables, - rpc: integrationsRpc(now), -} diff --git a/packages/ui-parity/src/fixtures/datasets/member.ts b/packages/ui-parity/src/fixtures/datasets/member.ts deleted file mode 100644 index 29576fac6..000000000 --- a/packages/ui-parity/src/fixtures/datasets/member.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Dataset } from "../dataset.ts" -import { defaultDataset, defaultIds } from "./default.ts" -import { integrationsDataset } from "./integrations.ts" - -/** - * The rich workspace seen by Alan, a plain member (non-admin), so permission-gated - * settings UI is hidden. Clerk and `user.me` both sign in as Alan. - */ -export const memberDataset: Dataset = { - ...integrationsDataset, - name: "member", - clerkOrgId: defaultDataset.clerkOrgId, - currentUser: { - ...defaultDataset.currentUser, - id: defaultIds.user("alan"), - clerkUserId: "user_alan", - role: "member", - firstName: "Alan", - lastName: "Turing", - email: "alan@hazel.test", - } as Dataset["currentUser"], -} - -/** Alan in the plain default workspace (no custom emojis), for member empty states. */ -export const memberEmptyDataset: Dataset = { - ...memberDataset, - name: "member-empty", - tables: defaultDataset.tables, - rpc: {}, -} diff --git a/packages/ui-parity/src/fixtures/datasets/onboarding.ts b/packages/ui-parity/src/fixtures/datasets/onboarding.ts deleted file mode 100644 index 964ca1020..000000000 --- a/packages/ui-parity/src/fixtures/datasets/onboarding.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" - -/** - * A user who just signed up: `isOnboarded: false`, no organization yet. Drives the - * creator onboarding flow (`/onboarding`) and the no-organization screens. - */ - -const now = new Date("2026-03-12T15:00:00.000Z") -const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000) - -const userId = stableId("user:nora") - -const users: Row[] = [ - { - id: userId, - externalId: "user_nora", - email: "nora@newcomer.test", - firstName: "Nora", - lastName: "Newcomer", - avatarUrl: null, - userType: "user", - settings: null, - isOnboarded: false, - timezone: null, - createdAt: minutesAgo(5), - updatedAt: null, - deletedAt: null, - }, -] - -export const onboardingDataset: Dataset = { - name: "onboarding", - now, - clerkOrgId: null, - currentUser: { - id: userId, - clerkUserId: "user_nora", - organizationId: null, - role: "member", - firstName: "Nora", - lastName: "Newcomer", - email: "nora@newcomer.test", - isOnboarded: false, - timezone: null, - settings: null, - } as Dataset["currentUser"], - tables: { users }, - rpc: {}, -} diff --git a/packages/ui-parity/src/fixtures/datasets/personal.ts b/packages/ui-parity/src/fixtures/datasets/personal.ts deleted file mode 100644 index a2ac217ce..000000000 --- a/packages/ui-parity/src/fixtures/datasets/personal.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" - -/** Datasets for the personal area: Ada's own preferences toggled, and an overflowing directory. */ - -const now = defaultDataset.now -const daysAgo = (days: number) => new Date(now.getTime() - days * 60 * 24 * 60_000) -const tables = defaultDataset.tables -const ada = defaultIds.user("ada") - -/** Ada has do-not-disturb on, custom quiet hours, and the quiet-hours status indicator off. */ -export const personalPrefsDataset: Dataset = { - ...defaultDataset, - name: "personal-prefs", - tables: { - ...tables, - users: (tables.users ?? []).map((user) => - user.id === ada - ? { - ...user, - settings: { - doNotDisturb: true, - quietHoursStart: "21:30", - quietHoursEnd: "07:15", - showQuietHoursInStatus: false, - }, - } - : user, - ), - }, -} - -const longFirst = "Augusta Ada Maximiliana" -const longLast = "King-Noel Lovelace-Byron of Ockham" - -/** Thirty extra members with long names, long channel names, and Ada with a very long name. */ -const extraPeople = Array.from({ length: 30 }, (_, index) => ({ - key: `member-${String(index).padStart(2, "0")}`, - firstName: ["Bartholomew", "Konstantina", "Maximilian", "Evangeline", "Wilhelmina"][index % 5]!, - lastName: `${["Featherstonehaugh", "Vandersloot-Abernathy", "Oyelaran-Castellanos"][index % 3]} ${index + 1}`, - role: index % 7 === 0 ? "admin" : "member", -})) - -const extraUsers: Row[] = extraPeople.map((person) => ({ - id: stableId(`user:${person.key}`), - externalId: `user_${person.key}`, - email: `${person.firstName.toLowerCase()}.${person.key}@a-very-long-company-domain-name.test`, - firstName: person.firstName, - lastName: person.lastName, - avatarUrl: null, - userType: "user", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: daysAgo(30), - updatedAt: null, - deletedAt: null, -})) - -const extraOrgMembers: Row[] = extraPeople.map((person, index) => ({ - id: stableId(`org-member:${person.key}`), - organizationId: defaultIds.orgId, - userId: stableId(`user:${person.key}`), - role: person.role, - nickname: null, - joinedAt: daysAgo(30 - index), - invitedBy: null, - deletedAt: null, - createdAt: daysAgo(30 - index), -})) - -const longChannels = [ - "cross-functional-platform-reliability-and-incident-response", - "customer-feedback-triage-for-the-enterprise-tier", - "quarterly-planning-and-roadmap-alignment-working-group", - "design-system-component-library-office-hours", -].map((name, index) => ({ key: `long-${index}`, name })) - -const extraChannels: Row[] = longChannels.map((channel) => ({ - id: stableId(`channel:${channel.key}`), - name: channel.name, - icon: null, - type: "public", - organizationId: defaultIds.orgId, - parentChannelId: null, - sectionId: null, - createdAt: daysAgo(10), - updatedAt: null, - deletedAt: null, -})) - -const extraChannelMembers: Row[] = longChannels.flatMap((channel, index) => - [ada, ...extraUsers.slice(0, 5 + index * 4).map((user) => user.id as string)].map((userId) => ({ - id: stableId(`channel-member:${channel.key}:${userId}`), - channelId: stableId(`channel:${channel.key}`), - userId, - isHidden: false, - isMuted: index === 3, - isFavorite: index === 0, - lastSeenMessageId: null, - notificationCount: userId === ada ? [120, 7, 0, 0][index] : 0, - joinedAt: daysAgo(10), - createdAt: daysAgo(10), - deletedAt: null, - })), -) - -export const personalOverflowDataset: Dataset = { - ...defaultDataset, - name: "personal-overflow", - currentUser: { ...defaultDataset.currentUser, firstName: longFirst, lastName: longLast }, - tables: { - ...tables, - users: [ - ...(tables.users ?? []).map((user) => - user.id === ada ? { ...user, firstName: longFirst, lastName: longLast } : user, - ), - ...extraUsers, - ], - organization_members: [...(tables.organization_members ?? []), ...extraOrgMembers], - channels: [...(tables.channels ?? []), ...extraChannels], - channel_members: [...(tables.channel_members ?? []), ...extraChannelMembers], - }, -} - -export const personalIds = { - longMember: stableId("user:member-00"), - missingUser: stableId("user:does-not-exist"), -} - -/** - * Legacy treats presence older than 45s as offline, and default seeds lastSeenAt 5 minutes ago. - * Here everyone but Linus was seen at `now`, so stored statuses (online, away, busy) show. - */ -export const personalPresenceDataset: Dataset = { - ...defaultDataset, - name: "personal-presence", - tables: { - ...tables, - user_presence_status: (tables.user_presence_status ?? []).map((row) => - row.userId === defaultIds.user("linus") ? row : { ...row, lastSeenAt: now, updatedAt: now }, - ), - }, -} diff --git a/packages/ui-parity/src/fixtures/datasets/rich.ts b/packages/ui-parity/src/fixtures/datasets/rich.ts deleted file mode 100644 index 89c8a9602..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { Dataset, Row } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset } from "./default.ts" -import { - asset, - at, - channelId, - channelKeys, - memberId, - orgId, - userId, - type PersonKey, - type RichChannelKey, -} from "./rich/base.ts" -import { - announcementMessages, - customEmojis, - launchMessages, - launchReactions, - longMessages, - pinnedMessages, - standupMessages, - threadMessages, - typingIndicators, -} from "./rich/conversation.ts" -import { - botRows, - botUsers, - chatSyncChannelLinks, - chatSyncConnections, - chatSyncMessageLinks, - integrationMessages, -} from "./rich/integrations.ts" -import { attachments, mediaMessages } from "./rich/media.ts" - -/** - * The `default` workspace plus every message feature: threads, embeds, attachments, pins, custom - * emoji, bots, a Discord-synced message, date separators, typing and a channel Ada hasn't joined. - */ - -const katherine: Row = { - id: userId("katherine"), - externalId: "user_katherine", - email: "katherine@hazel.test", - firstName: "Katherine", - lastName: "Johnson", - // The one teammate with an uploaded avatar, served by the fixture backend. - avatarUrl: asset("avatars/katherine-128x128.png"), - userType: "user", - settings: null, - isOnboarded: true, - timezone: "UTC", - createdAt: at(-90, "12:00"), - updatedAt: null, - deletedAt: null, -} - -const channelNames: Record = { - launch: "launch", - "launch-thread": "Launch checklist", - github: "github", - deploys: "deploys", - status: "status", - links: "links", - media: "media", - "long-reads": "long-reads", - standup: "standup", - announcements: "announcements", -} - -const channels: Row[] = channelKeys.map((key) => ({ - id: channelId(key), - name: channelNames[key], - icon: null, - type: key === "launch-thread" ? "thread" : "public", - organizationId: orgId, - parentChannelId: key === "launch-thread" ? channelId("launch") : null, - sectionId: null, - createdAt: at(-30, "12:00"), - updatedAt: null, - deletedAt: null, -})) - -const everyone: PersonKey[] = ["ada", "grace", "alan", "margaret", "linus", "katherine"] -const channelMembers: Row[] = channelKeys.flatMap((channel) => - everyone - .filter((person) => !(channel === "announcements" && person === "ada")) - .map((person) => ({ - id: memberId(channel, person), - channelId: channelId(channel), - userId: userId(person), - isHidden: false, - isMuted: false, - isFavorite: false, - lastSeenMessageId: null, - notificationCount: 0, - joinedAt: at(-30, "12:00"), - createdAt: at(-30, "12:00"), - deletedAt: null, - })), -) - -const base = defaultDataset.tables - -export const richDataset: Dataset = { - ...defaultDataset, - name: "rich", - tables: { - ...base, - users: [...base.users!, katherine, ...botUsers], - organization_members: [ - ...base.organization_members!, - { - id: stableId("rich:org-member:katherine"), - organizationId: orgId, - userId: userId("katherine"), - role: "member", - nickname: null, - joinedAt: at(-90, "12:00"), - invitedBy: null, - deletedAt: null, - createdAt: at(-90, "12:00"), - }, - ], - channels: [...base.channels!, ...channels], - channel_members: [...base.channel_members!, ...channelMembers], - messages: [ - ...base.messages!, - ...launchMessages, - ...threadMessages, - ...longMessages, - ...standupMessages, - ...announcementMessages, - ...integrationMessages, - ...mediaMessages, - ], - message_reactions: [...base.message_reactions!, ...launchReactions], - pinned_messages: pinnedMessages, - custom_emojis: customEmojis, - attachments, - bots: botRows, - typing_indicators: typingIndicators, - chat_sync_connections: chatSyncConnections, - chat_sync_channel_links: chatSyncChannelLinks, - chat_sync_message_links: chatSyncMessageLinks, - user_presence_status: [ - ...base.user_presence_status!, - { - id: stableId("rich:presence:katherine"), - userId: userId("katherine"), - status: "online", - customMessage: "Shipping the launch", - statusEmoji: "🚀", - statusExpiresAt: null, - activeChannelId: null, - suppressNotifications: false, - updatedAt: at(0, "14:55"), - lastSeenAt: at(0, "14:55"), - }, - ], - }, -} - -/** Scenario paths into the rich workspace. */ -export const richChat = (key: RichChannelKey) => `/hazel/chat/${channelId(key)}` diff --git a/packages/ui-parity/src/fixtures/datasets/rich/base.ts b/packages/ui-parity/src/fixtures/datasets/rich/base.ts deleted file mode 100644 index fd71ebc53..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich/base.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { fixtureBackendUrl } from "../../../config.ts" -import type { Row } from "../../dataset.ts" -import { stableId } from "../../ids.ts" -import { defaultDataset, defaultIds } from "../default.ts" - -/** Shared helpers for the `rich` dataset. Same org, people and clock as `default`. */ - -export const now = defaultDataset.now -export const orgId = defaultIds.orgId - -/** A wall-clock time `days` before the dataset's `now` (UTC), e.g. `at(-1, "09:15")`. */ -export const at = (days: number, time: string) => { - const [hours, minutes] = time.split(":").map(Number) - const date = new Date(now) - date.setUTCDate(date.getUTCDate() + days) - date.setUTCHours(hours!, minutes!, 0, 0) - return date -} - -/** Fixture image served by `backend/assets.ts`. Embed a size as `name-x.png`. */ -export const asset = (path: string) => `${fixtureBackendUrl}/r2/${path}` - -export type PersonKey = "ada" | "grace" | "alan" | "margaret" | "linus" | "katherine" -export type BotKey = "github" | "railway" | "openstatus" - -export const userId = (key: PersonKey | BotKey) => - key === "katherine" || key === "github" || key === "railway" || key === "openstatus" - ? stableId(`rich:user:${key}`) - : defaultIds.user(key) - -export const channelKeys = [ - "launch", - "launch-thread", - "github", - "deploys", - "status", - "links", - "media", - "long-reads", - "standup", - "announcements", -] as const -export type RichChannelKey = (typeof channelKeys)[number] -export const channelId = (key: RichChannelKey) => stableId(`rich:channel:${key}`) -export const messageId = (key: string) => stableId(`rich:message:${key}`) -export const memberId = (channel: RichChannelKey, person: PersonKey) => - stableId(`rich:channel-member:${channel}:${person}`) - -export const message = (input: { - readonly key: string - readonly channel: RichChannelKey - readonly author: PersonKey | BotKey - readonly createdAt: Date - readonly content: string - readonly embeds?: ReadonlyArray> - readonly replyTo?: string - readonly thread?: RichChannelKey - readonly editedAt?: Date -}): Row => ({ - id: messageId(input.key), - channelId: channelId(input.channel), - conversationId: null, - authorId: userId(input.author), - content: input.content, - embeds: input.embeds ?? null, - replyToMessageId: input.replyTo ? messageId(input.replyTo) : null, - threadChannelId: input.thread ? channelId(input.thread) : null, - createdAt: input.createdAt, - updatedAt: input.editedAt ?? null, - deletedAt: null, -}) - -export const reaction = ( - messageKey: string, - channel: RichChannelKey, - person: PersonKey, - emoji: string, -): Row => ({ - id: stableId(`rich:reaction:${messageKey}:${person}:${emoji}`), - messageId: messageId(messageKey), - channelId: channelId(channel), - conversationId: null, - userId: userId(person), - emoji, - createdAt: at(0, "12:00"), -}) diff --git a/packages/ui-parity/src/fixtures/datasets/rich/conversation.ts b/packages/ui-parity/src/fixtures/datasets/rich/conversation.ts deleted file mode 100644 index f74c2e389..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich/conversation.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { Row } from "../../dataset.ts" -import { stableId } from "../../ids.ts" -import { asset, at, channelId, memberId, message, messageId, now, orgId, reaction, userId } from "./base.ts" - -/** - * #launch: three days of conversation (date separators), grouped runs, a reply, an edit, custom - * emoji inline and as reactions, a mention, a Discord-synced message, two pins and a thread. - */ - -export const customEmojiUrl = (name: string) => asset(`emojis/${name}-64x64.png`) -const emoji = (name: string) => `![custom-emoji:${name}](${customEmojiUrl(name)})` - -export const launchMessages: Row[] = [ - message({ - key: "launch:kickoff", - channel: "launch", - author: "grace", - createdAt: at(-2, "10:00"), - content: "Kicking off the launch channel. Everything for Thursday's release lives here.", - }), - message({ - key: "launch:kickoff-2", - channel: "launch", - author: "grace", - createdAt: at(-2, "10:01"), - content: "The docs draft is up for review, comments welcome.", - }), - message({ - key: "launch:kickoff-3", - channel: "launch", - author: "grace", - createdAt: at(-2, "10:02"), - content: "Standup moves to 09:30 until we ship.", - }), - message({ - key: "launch:runbook", - channel: "launch", - author: "alan", - createdAt: at(-2, "11:30"), - content: "I'll own the migration runbook.", - }), - message({ - key: "launch:plan", - channel: "launch", - author: "margaret", - createdAt: at(-1, "09:15"), - content: - 'Rollout plan for the sync service:\n\n1. Ship behind the `sync_v2` flag\n2. Ramp to 10% of orgs\n3. Watch error rates for an hour\n\n```ts\nawait flags.enable("sync_v2", { percent: 10 })\n```', - }), - message({ - key: "launch:plan-reply", - channel: "launch", - author: "ada", - createdAt: at(-1, "09:20"), - content: "Agreed. Let's hold the ramp if p95 latency goes above 400ms.", - replyTo: "launch:plan", - }), - message({ - key: "launch:rc", - channel: "launch", - author: "linus", - createdAt: at(-1, "14:00"), - content: `Release candidate is green ${emoji("shipit")} all suites passing ${emoji("partyparrot")}`, - }), - message({ - key: "launch:edited", - channel: "launch", - author: "katherine", - createdAt: at(-1, "16:00"), - editedAt: at(-1, "16:05"), - content: "Status page copy is final, screenshots go out tomorrow morning.", - }), - message({ - key: "launch:checklist", - channel: "launch", - author: "ada", - createdAt: at(0, "13:10"), - content: "Launch checklist: please claim your items in the thread.", - thread: "launch-thread", - }), - message({ - key: "launch:window", - channel: "launch", - author: "grace", - createdAt: at(0, "13:40"), - content: "Launch window confirmed: **Thursday 17:00 UTC**.", - }), - message({ - key: "launch:discord", - channel: "launch", - author: "alan", - createdAt: at(0, "14:20"), - content: "Community folks on Discord are asking about the changelog.", - }), - message({ - key: "launch:mention", - channel: "launch", - author: "katherine", - createdAt: at(0, "14:50"), - content: `@[userId:${userId("ada")}] can you confirm the status page copy before 16:00?`, - }), -] - -export const threadMessages: Row[] = [ - ["alan", "13:15", "Taking the database migration and the rollback drill."], - ["margaret", "13:22", "I've got the status page and customer email."], - ["linus", "13:31", "CI freeze and release tagging are mine."], -].map(([author, time, content], index) => - message({ - key: `launch-thread:${index}`, - channel: "launch-thread", - author: author as "alan", - createdAt: at(0, time!), - content: content!, - }), -) - -export const launchReactions: Row[] = [ - reaction("launch:rc", "launch", "ada", "custom:shipit"), - reaction("launch:rc", "launch", "grace", "custom:shipit"), - reaction("launch:rc", "launch", "margaret", "🎉"), - reaction("launch:window", "launch", "alan", "👍"), - reaction("launch:window", "launch", "linus", "👍"), - reaction("launch:window", "launch", "katherine", "custom:partyparrot"), -] - -export const pinnedMessages: Row[] = (["launch:checklist", "launch:window"] as const).map((key, index) => ({ - id: stableId(`rich:pin:${key}`), - channelId: channelId("launch"), - messageId: messageId(key), - pinnedBy: userId("ada"), - pinnedAt: at(0, index === 0 ? "13:11" : "13:41"), -})) - -export const customEmojis: Row[] = ["shipit", "partyparrot", "hazel"].map((name) => ({ - id: stableId(`rich:emoji:${name}`), - organizationId: orgId, - name, - imageUrl: customEmojiUrl(name), - createdBy: userId("ada"), - createdAt: at(-30, "12:00"), - updatedAt: null, - deletedAt: null, -})) - -/** #long-reads: one message long enough to overflow, with a wide code line and a long token. */ -export const longMessages: Row[] = [ - message({ - key: "long:essay", - channel: "long-reads", - author: "margaret", - createdAt: at(0, "11:00"), - content: [ - "Postmortem draft for the March 4 sync outage, please read before Friday.", - "**Summary.** For 47 minutes, message sync stalled for roughly a third of organizations. Writes were accepted and persisted, but shape streams stopped advancing, so clients saw stale channels until they reloaded. No data was lost.", - "**Timeline.** At 14:02 a config push lowered the replication slot's max lag. At 14:09 the first slot fell behind and was dropped, which forced every client on that shard into a full resync. The resync storm saturated the connection pool, and at 14:20 the remaining shards followed. We rolled the config back at 14:41 and streams recovered by 14:49.", - "**What went well.** Alerting fired within two minutes, and the rollback was a single command. The incident channel stayed focused and the status page was updated three times.", - "**What went poorly.** The config change was reviewed as a cosmetic rename. Our staging environment has one shard, so the cascade could not reproduce there. Resync has no backoff, so every client retried at the same moment.", - "**Action items.** Add jittered backoff to resync, alert on slot lag rather than slot drops, and require a load test for any change under `replication/`.", - '```\nERROR replication slot "shape_slot_07" dropped: max_slot_wal_keep_size exceeded (requested 2147483648, limit 1073741824) at lsn 0/4F2A9C18\n```', - "Raw trace id for reference: 7f3a9c2e1b4d4e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", - ].join("\n\n"), - }), -] - -/** #standup: a short exchange while two teammates are typing. */ -export const standupMessages: Row[] = [ - message({ - key: "standup:0", - channel: "standup", - author: "grace", - createdAt: at(0, "09:30"), - content: "Standup thread for today, post your updates here.", - }), - message({ - key: "standup:1", - channel: "standup", - author: "ada", - createdAt: at(0, "09:34"), - content: "Yesterday: onboarding copy. Today: launch checklist.", - }), -] - -export const typingIndicators: Row[] = (["grace", "alan"] as const).map((person, index) => ({ - id: stableId(`rich:typing:${person}`), - channelId: channelId("standup"), - memberId: memberId("standup", person), - lastTyped: now.getTime() - 1000 - index * 500, -})) - -/** #announcements: Ada isn't a member, so the channel shows the join banner. */ -export const announcementMessages: Row[] = [ - message({ - key: "announcements:0", - channel: "announcements", - author: "grace", - createdAt: at(-3, "10:00"), - content: "Welcome to announcements. Company-wide news only.", - }), -] diff --git a/packages/ui-parity/src/fixtures/datasets/rich/emoji.ts b/packages/ui-parity/src/fixtures/datasets/rich/emoji.ts deleted file mode 100644 index 4d99c7f76..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich/emoji.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Page } from "playwright" - -/** - * The emoji picker (frimousse) downloads emojibase data from a CDN, which captures block. It reads - * a cached copy from storage first, so scenarios seed this small fixed set before opening it. - */ - -const categories = [ - { - index: 0, - label: "Smileys & emotion", - emojis: "😀😃😄😁😆😅🤣😂🙂🙃😉😊😇🥰😍🤩😘😗😚😙😋😛😜🤪😝🤑🤗🤭", - }, - { index: 1, label: "People & body", emojis: "👋🤚🖐✋🖖👌🤌🤏✌🤞🤟🤘🤙👈👉👆🖕👇☝👍👎✊👊🤛🤜👏🙌👐🤲🙏" }, - { index: 3, label: "Animals & nature", emojis: "🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐵🐔🐧🐦🐤🦆🦅🦉" }, - { index: 4, label: "Food & drink", emojis: "🍏🍎🍐🍊🍋🍌🍉🍇🍓🫐🍈🍒🍑🥭🍍🥥🥝🍅🍆🥑🥦" }, - { index: 6, label: "Activities", emojis: "🎃🎄🎆🎇🧨✨🎈🎉🎊🎋🎍🎎🎏🎐🎑🧧🎀🎁🎗🎟🎫" }, - { index: 8, label: "Symbols", emojis: "❤🧡💛💚💙💜🖤🤍🤎💔❣💕💞💓💗💖💘💝✅❌⭐🔥💯" }, -] - -const emojiData = { - data: { - locale: "en", - emojis: categories.flatMap((category) => - [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(category.emojis)].map( - ({ segment }, index) => ({ - emoji: segment, - category: category.index, - label: `${category.label} ${index + 1}`, - version: 1, - tags: [], - }), - ), - ), - categories: categories.map(({ index, label }) => ({ index, label })), - skinTones: { - light: "Light skin tone", - "medium-light": "Medium-light skin tone", - medium: "Medium skin tone", - "medium-dark": "Medium-dark skin tone", - dark: "Dark skin tone", - }, - }, - metadata: { emojisEtag: null, messagesEtag: null }, -} - -/** Seeds frimousse's cache for the current page; call before opening a picker. */ -export const seedEmojiPicker = (page: Page) => - page.evaluate((data) => { - localStorage.setItem("frimousse/data/en", JSON.stringify(data)) - sessionStorage.setItem("frimousse/metadata", JSON.stringify({ emojiVersion: 1, countryFlags: false })) - }, emojiData) diff --git a/packages/ui-parity/src/fixtures/datasets/rich/integrations.ts b/packages/ui-parity/src/fixtures/datasets/rich/integrations.ts deleted file mode 100644 index 521c725ef..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich/integrations.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { Row } from "../../dataset.ts" -import { stableId } from "../../ids.ts" -import { asset, at, channelId, message, messageId, orgId, userId, type BotKey } from "./base.ts" - -/** - * Bot users and their messages: GitHub, Railway and OpenStatus webhook embeds, modelled on the - * builders in `@hazel/integrations/*` (see `apps/web/src/routes/dev/embeds`), but with fixed - * timestamps and icons served by the fixture backend. Plus a Discord-synced message. - */ - -const bots: ReadonlyArray<{ key: BotKey; name: string; description: string }> = [ - { key: "github", name: "GitHub", description: "Pull requests, pushes and CI runs" }, - { key: "railway", name: "Railway", description: "Deployment notifications" }, - { key: "openstatus", name: "OpenStatus", description: "Uptime monitoring" }, -] - -export const botUsers: Row[] = bots.map((bot) => ({ - id: userId(bot.key), - externalId: `bot_${bot.key}`, - email: `${bot.key}@bots.hazel.test`, - firstName: bot.name, - lastName: "", - avatarUrl: asset(`avatars/${bot.key}-128x128.png`), - userType: "machine", - settings: null, - isOnboarded: true, - timezone: null, - createdAt: at(-60, "12:00"), - updatedAt: null, - deletedAt: null, -})) - -export const botRows: Row[] = bots.map((bot) => ({ - id: stableId(`rich:bot:${bot.key}`), - userId: userId(bot.key), - createdBy: userId("ada"), - name: bot.name, - description: bot.description, - webhookUrl: null, - apiTokenHash: `fixture-token-hash-${bot.key}`, - scopes: null, - metadata: null, - isPublic: false, - installCount: 1, - allowedIntegrations: null, - mentionable: false, - createdAt: at(-60, "12:00"), - updatedAt: null, - deletedAt: null, -})) - -const iso = (date: Date) => date.toISOString() -const githubFooter = { text: "hazel/app", iconUrl: asset("icons/github-64x64.png") } -const octocat = { - name: "octocat", - url: "https://github.com/octocat", - iconUrl: asset("avatars/octocat-64x64.png"), -} - -export const integrationMessages: Row[] = [ - message({ - key: "github:pr", - channel: "github", - author: "github", - createdAt: at(0, "10:12"), - content: "", - embeds: [ - { - title: "#41 Fix database connection pooling", - description: "Resolved the connection leak that caused timeouts under high load.", - url: "https://github.com/hazel/app/pull/41", - color: 9000933, - author: octocat, - footer: githubFooter, - fields: [ - { name: "Diff", value: "{green:+89} {red:-23} (112 lines)", inline: true }, - { name: "Labels", value: "[[bug]]", inline: true }, - ], - timestamp: iso(at(0, "10:12")), - badge: { text: "Merged", color: 9000933 }, - }, - ], - }), - message({ - key: "github:push", - channel: "github", - author: "github", - createdAt: at(0, "11:40"), - content: "", - embeds: [ - { - title: "hazel/app", - description: "**3** commits pushed to [[feature/new-api]]", - url: "https://github.com/hazel/app/compare/abc...xyz", - color: 3056719, - author: octocat, - footer: { text: "GitHub", iconUrl: asset("icons/github-64x64.png") }, - fields: [ - { name: "`abc1234`", value: "feat: add user authentication", inline: false }, - { name: "`def2345`", value: "feat: add API endpoints", inline: false }, - { name: "`ghi3456`", value: "test: add unit tests", inline: false }, - ], - timestamp: iso(at(0, "11:40")), - badge: { text: "Push", color: 3056719 }, - }, - ], - }), - message({ - key: "railway:crashed", - channel: "deploys", - author: "railway", - createdAt: at(0, "12:05"), - content: "", - embeds: [ - { - title: "worker-service in hazel-app", - description: "Environment: [[info:production]]", - color: 15680580, - author: { - name: "Railway", - url: "https://railway.app", - iconUrl: asset("icons/railway-64x64.png"), - }, - footer: { text: "Hazel Team" }, - fields: [ - { name: "Branch", value: "main", type: "badge", inline: true }, - { name: "Commit", value: "`c7d9a2b` - refactor: update dependencies", inline: false }, - { - name: "Severity", - value: "ERROR", - type: "badge", - options: { intent: "danger" }, - inline: true, - }, - ], - timestamp: iso(at(0, "12:05")), - badge: { text: "Crashed", color: 15680580 }, - }, - ], - }), - message({ - key: "railway:deployed", - channel: "deploys", - author: "railway", - createdAt: at(0, "12:30"), - content: "", - embeds: [ - { - title: "worker-service in hazel-app", - description: "Environment: [[info:production]]", - color: 2278750, - author: { - name: "Railway", - url: "https://railway.app", - iconUrl: asset("icons/railway-64x64.png"), - }, - footer: { text: "Hazel Team" }, - fields: [ - { name: "Branch", value: "main", type: "badge", inline: true }, - { name: "Commit", value: "`d81e4f0` - fix: pin worker memory limit", inline: false }, - ], - timestamp: iso(at(0, "12:30")), - badge: { text: "Deployed", color: 2278750 }, - }, - ], - }), - message({ - key: "openstatus:down", - channel: "status", - author: "openstatus", - createdAt: at(0, "13:02"), - content: "", - embeds: [ - { - title: "Monitor Down", - color: 15680580, - author: { name: "Web Application", iconUrl: asset("icons/openstatus-64x64.png") }, - fields: [ - { name: "URL", value: "https://app.example.com", inline: false }, - { name: "Error", value: "Service Unavailable", inline: false }, - ], - timestamp: iso(at(0, "13:02")), - badge: { text: "Error", color: 15680580 }, - }, - ], - }), - message({ - key: "openstatus:recovered", - channel: "status", - author: "openstatus", - createdAt: at(0, "13:09"), - content: "", - embeds: [ - { - title: "Monitor Recovered", - color: 2278750, - author: { name: "Web Application", iconUrl: asset("icons/openstatus-64x64.png") }, - fields: [ - { name: "URL", value: "https://app.example.com", inline: false }, - { name: "Latency", value: "182ms", inline: true }, - ], - timestamp: iso(at(0, "13:09")), - badge: { text: "Recovered", color: 2278750 }, - }, - ], - }), - // URL shares (link previews, tweets, YouTube) need link-preview.hazel.sh or youtube.com, which - // captures block, so #links only has a rich embed that renders from fixture assets. - message({ - key: "links:embed", - channel: "links", - author: "grace", - createdAt: at(0, "10:20"), - content: "Rich embed with a thumbnail and an image, no network needed.", - embeds: [ - { - title: "Hazel 2.0 release notes", - description: "Threads, custom emoji and a faster sync engine.", - url: "https://hazel.sh/changelog/2-0", - color: 7506394, - author: { name: "Hazel Changelog", iconUrl: asset("icons/hazel-64x64.png") }, - thumbnail: { url: asset("embeds/thumbnail-160x160.png") }, - image: { url: asset("embeds/hero-800x400.png") }, - footer: { text: "hazel.sh" }, - timestamp: iso(at(0, "10:20")), - }, - ], - }), -] - -/** Discord bridge: one #launch message arrived from Discord. */ -const connectionId = stableId("rich:chat-sync:discord") -const channelLinkId = stableId("rich:chat-sync:discord:launch") - -export const chatSyncConnections: Row[] = [ - { - id: connectionId, - organizationId: orgId, - integrationConnectionId: null, - provider: "discord", - externalWorkspaceId: "discord-guild-1", - externalWorkspaceName: "Hazel Community", - status: "active", - settings: null, - metadata: null, - errorMessage: null, - lastSyncedAt: at(0, "14:20"), - createdBy: userId("ada"), - createdAt: at(-20, "12:00"), - updatedAt: null, - deletedAt: null, - }, -] - -export const chatSyncChannelLinks: Row[] = [ - { - id: channelLinkId, - syncConnectionId: connectionId, - hazelChannelId: channelId("launch"), - externalChannelId: "discord-channel-launch", - externalChannelName: "launch", - direction: "both", - isActive: true, - settings: null, - lastSyncedAt: at(0, "14:20"), - createdAt: at(-20, "12:00"), - updatedAt: null, - deletedAt: null, - }, -] - -export const chatSyncMessageLinks: Row[] = [ - { - id: stableId("rich:chat-sync:message:launch:discord"), - channelLinkId, - hazelMessageId: messageId("launch:discord"), - externalMessageId: "discord-message-1", - source: "external", - rootHazelMessageId: null, - rootExternalMessageId: null, - hazelThreadChannelId: null, - externalThreadId: null, - lastSyncedAt: at(0, "14:20"), - createdAt: at(0, "14:20"), - updatedAt: null, - deletedAt: null, - }, -] diff --git a/packages/ui-parity/src/fixtures/datasets/rich/media.ts b/packages/ui-parity/src/fixtures/datasets/rich/media.ts deleted file mode 100644 index bc2545200..000000000 --- a/packages/ui-parity/src/fixtures/datasets/rich/media.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Row } from "../../dataset.ts" -import { stableId } from "../../ids.ts" -import { asset, at, channelId, message, messageId, orgId, userId, type PersonKey } from "./base.ts" - -/** - * #media: image grids (1, 3 and 5 images), document attachments and a video. Images load from the - * fixture backend through `externalUrl`; documents and the video are never fetched by a capture. - */ - -const posts: ReadonlyArray<{ - key: string - author: PersonKey - time: string - content: string - files: ReadonlyArray -}> = [ - { - key: "media:moodboard", - author: "katherine", - time: "10:00", - content: "Moodboard for the launch page", - files: [1, 2, 3, 4, 5].map((n) => [ - `moodboard-${n}.png`, - 240_000 + n * 1000, - `moodboard-${n}-600x600.png`, - ]), - }, - { - key: "media:screenshot", - author: "grace", - time: "11:15", - content: "New dashboard screenshot", - files: [["dashboard.png", 512_000, "dashboard-1280x720.png"]], - }, - { - key: "media:trio", - author: "alan", - time: "12:30", - content: "Icon explorations", - files: [1, 2, 3].map((n) => [`icon-${n}.png`, 32_000 + n * 1000, `icon-${n}-400x400.png`]), - }, - { - key: "media:docs", - author: "margaret", - time: "13:45", - content: "Launch plan and the metrics export", - files: [ - ["launch-plan.pdf", 1_482_000], - ["metrics-export.csv", 86_500], - ["press-kit.zip", 24_800_000], - ], - }, - { - key: "media:video", - author: "linus", - time: "14:30", - content: "Screen recording of the new onboarding", - files: [["onboarding-walkthrough.mp4", 18_400_000]], - }, -] - -export const mediaMessages: Row[] = posts.map((post) => - message({ - key: post.key, - channel: "media", - author: post.author, - createdAt: at(0, post.time), - content: post.content, - }), -) - -export const attachments: Row[] = posts.flatMap((post) => - post.files.map(([fileName, fileSize, url], index) => ({ - id: stableId(`rich:attachment:${post.key}:${index}`), - organizationId: orgId, - channelId: channelId("media"), - messageId: messageId(post.key), - fileName, - fileSize, - externalUrl: url ? asset(`attachments/${url}`) : asset(`attachments/${fileName}`), - uploadedBy: userId(post.author), - status: "complete", - uploadedAt: new Date(at(0, post.time).getTime() + index * 1000), - deletedAt: null, - })), -) diff --git a/packages/ui-parity/src/fixtures/datasets/status-emoji.ts b/packages/ui-parity/src/fixtures/datasets/status-emoji.ts deleted file mode 100644 index 2b8d77a17..000000000 --- a/packages/ui-parity/src/fixtures/datasets/status-emoji.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { UserPresenceStatus } from "@hazel/domain/models" -import { UserPresenceStatusResponse } from "@hazel/domain/rpc" -import { TransactionId, UserId, UserPresenceStatusId } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" -import { pushChange } from "../../backend/live-events.ts" -import type { Dataset } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" - -/** - * Ada with an emoji status (the user menu shows it next to her name and on "Set status"), plus - * variants whose status writes sync back like Electric would: the RPC pushes the new presence row. - */ - -const now = defaultDataset.now -const adaPresenceId = stableId("presence:ada") - -export const ADA_STATUS_EMOJI = "🌴" -export const ADA_STATUS_MESSAGE = "Vacationing" - -interface AdaStatus { - readonly statusEmoji: string | null - readonly customMessage: string | null - readonly statusExpiresAt: Date | null - readonly suppressNotifications: boolean -} - -const noStatus: AdaStatus = { - statusEmoji: null, - customMessage: null, - statusExpiresAt: null, - suppressNotifications: false, -} - -const emojiStatus: AdaStatus = { - statusEmoji: ADA_STATUS_EMOJI, - customMessage: ADA_STATUS_MESSAGE, - statusExpiresAt: new Date(now.getTime() + 2 * 60 * 60 * 1000), - suppressNotifications: false, -} - -const emojiStatusTables: Dataset["tables"] = { - ...defaultDataset.tables, - user_presence_status: (defaultDataset.tables.user_presence_status ?? []).map((row) => - row.id === adaPresenceId ? { ...row, ...emojiStatus } : row, - ), -} - -/** Ada's presence row (the default dataset's, online) with a custom status. */ -const adaPresence = (status: AdaStatus) => - UserPresenceStatus.Schema.make({ - id: Schema.decodeSync(UserPresenceStatusId)(adaPresenceId), - userId: Schema.decodeSync(UserId)(defaultIds.user("ada")), - status: "online", - activeChannelId: null, - updatedAt: now, - lastSeenAt: now, - ...status, - }) - -const respond = (status: AdaStatus) => - new UserPresenceStatusResponse({ - data: adaPresence(status), - transactionId: Schema.decodeSync(TransactionId)(1), - }) - -/** Postgres text encoding, as `/__parity/push` expects it. */ -const encodeRow = (row: Readonly>): Record => - Object.fromEntries( - Object.entries(row).map(([column, value]) => [ - column, - value === null || value === undefined - ? null - : value instanceof Date - ? value.toISOString() - : String(value), - ]), - ) - -/** Writes Ada's row back through the live shape, like Electric after the real RPC. */ -const syncAdaStatus = (datasetName: string, status: AdaStatus) => - Effect.sync(() => { - pushChange(datasetName, { - table: "user_presence_status", - operation: "update", - row: encodeRow({ ...adaPresence(status) }), - }) - return respond(status) - }) - -/** `setCustomStatus` payloads; presence status syncs carry no `statusEmoji`. */ -const CustomStatusPayload = Schema.Struct({ - statusEmoji: Schema.NullOr(Schema.String), - customMessage: Schema.NullOr(Schema.String), - statusExpiresAt: Schema.optionalKey(Schema.NullOr(Schema.Date)), - suppressNotifications: Schema.optionalKey(Schema.Boolean), -}) - -const presenceWrites = (datasetName: string, initial: AdaStatus): Dataset["rpc"] => ({ - "userPresenceStatus.update": (payload) => - Option.match(Schema.decodeUnknownOption(CustomStatusPayload)(payload), { - onNone: () => respond(initial), - onSome: (status) => - syncAdaStatus(datasetName, { - statusEmoji: status.statusEmoji, - customMessage: status.customMessage, - statusExpiresAt: status.statusExpiresAt ?? null, - suppressNotifications: status.suppressNotifications ?? false, - }), - }), - "userPresenceStatus.clearStatus": () => syncAdaStatus(datasetName, noStatus), -}) - -export const statusEmojiDataset: Dataset = { - ...defaultDataset, - name: "status-emoji", - tables: emojiStatusTables, -} - -/** No status yet; saving one shows its emoji in the user menu. */ -export const statusEmojiSaveDataset: Dataset = { - ...defaultDataset, - name: "status-emoji-save", - rpc: { ...defaultDataset.rpc, ...presenceWrites("status-emoji-save", noStatus) }, -} - -/** The emoji status set; clearing it removes the emoji from the user menu. */ -export const statusEmojiClearDataset: Dataset = { - ...defaultDataset, - name: "status-emoji-clear", - tables: emojiStatusTables, - rpc: { ...defaultDataset.rpc, ...presenceWrites("status-emoji-clear", emojiStatus) }, -} diff --git a/packages/ui-parity/src/fixtures/datasets/status-set.ts b/packages/ui-parity/src/fixtures/datasets/status-set.ts deleted file mode 100644 index 2d7969a75..000000000 --- a/packages/ui-parity/src/fixtures/datasets/status-set.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { UserPresenceStatus } from "@hazel/domain/models" -import { UserPresenceStatusResponse } from "@hazel/domain/rpc" -import { TransactionId, UserId, UserPresenceStatusId } from "@hazel/schema" -import { Effect, Schema } from "effect" -import type { Dataset } from "../dataset.ts" -import { stableId } from "../ids.ts" -import { defaultDataset, defaultIds } from "./default.ts" - -/** - * The default workspace with Ada's custom status message set (notifications paused), for the - * set-status modal's "Clear status" state. No emoji: the user menu's status emoji is not ported yet. - * Status writes never settle here, so Save shows "Saving..."; clearing succeeds. - */ - -const now = defaultDataset.now -const adaPresenceId = stableId("presence:ada") - -export const ADA_STATUS_MESSAGE = "Vacationing" - -const adaPresence = UserPresenceStatus.Schema.make({ - id: Schema.decodeSync(UserPresenceStatusId)(adaPresenceId), - userId: Schema.decodeSync(UserId)(defaultIds.user("ada")), - status: "online", - customMessage: null, - statusEmoji: null, - statusExpiresAt: null, - activeChannelId: null, - suppressNotifications: false, - updatedAt: now, - lastSeenAt: now, -}) - -export const statusSetDataset: Dataset = { - ...defaultDataset, - name: "status-set", - tables: { - ...defaultDataset.tables, - user_presence_status: (defaultDataset.tables.user_presence_status ?? []).map((row) => - row.id === adaPresenceId - ? { - ...row, - customMessage: ADA_STATUS_MESSAGE, - suppressNotifications: true, - } - : row, - ), - }, - rpc: { - ...defaultDataset.rpc, - "userPresenceStatus.update": () => Effect.never, - "userPresenceStatus.clearStatus": () => - new UserPresenceStatusResponse({ - data: adaPresence, - transactionId: Schema.decodeSync(TransactionId)(1), - }), - }, -} diff --git a/packages/ui-parity/src/fixtures/identity.ts b/packages/ui-parity/src/fixtures/identity.ts deleted file mode 100644 index f305b7381..000000000 --- a/packages/ui-parity/src/fixtures/identity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ClerkStubIdentity } from "../runtime/clerk-stub.ts" -import type { Dataset } from "./dataset.ts" - -/** - * The Clerk identity a dataset signs in as. Captures inject it per scenario, `serve` per server. - * Signed-out datasets get `null`: the stub then has no session, user or organization. - */ -export const clerkIdentityFor = (dataset: Dataset): ClerkStubIdentity | null => - dataset.signedOut - ? null - : { - clerkUserId: dataset.currentUser.clerkUserId, - email: dataset.currentUser.email, - firstName: dataset.currentUser.firstName ?? "", - lastName: dataset.currentUser.lastName ?? "", - imageUrl: "", - clerkOrgId: dataset.clerkOrgId, - } diff --git a/packages/ui-parity/src/fixtures/ids.ts b/packages/ui-parity/src/fixtures/ids.ts deleted file mode 100644 index c2168a0f3..000000000 --- a/packages/ui-parity/src/fixtures/ids.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createHash } from "node:crypto" - -/** - * Deterministic UUID derived from a readable key, so fixtures can reference each - * other by name (`stableId("user:ada")`) and every run produces identical IDs. - */ -export const stableId = (key: string): A => { - const hex = createHash("sha1").update(`hazel-parity:${key}`).digest("hex") - const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16) - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}` as A -} diff --git a/packages/ui-parity/src/report.ts b/packages/ui-parity/src/report.ts deleted file mode 100644 index 99ee8e84b..000000000 --- a/packages/ui-parity/src/report.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { writeFileSync } from "node:fs" -import { join, relative } from "node:path" -import type { CaptureResult, CaptureVariant } from "./capture.ts" -import { ariaRole } from "./behavior.ts" -import type { VariantComparison } from "./compare.ts" - -export interface RunSummary { - readonly run: string - readonly baseline: string - readonly candidate: string - readonly createdAt: string - readonly totals: Record - readonly variants: ReadonlyArray<{ - readonly variant: { - readonly id: string - readonly scenario: string - readonly title: string - readonly area: string - readonly viewport: string - readonly theme: string - readonly path: string - } - readonly comparison: VariantComparison - readonly baselineCapture?: CaptureResult - readonly candidateCapture?: CaptureResult - }> -} - -export const buildSummary = (input: { - run: string - baseline: string - candidate: string - variants: ReadonlyArray - comparisons: ReadonlyArray - baselineResults: ReadonlyArray - candidateResults: ReadonlyArray -}): RunSummary => { - const totals = { identical: 0, pass: 0, fail: 0, missing: 0 } - const variants = input.variants.map((variant, index) => { - const comparison = input.comparisons[index]! - totals[comparison.status]++ - return { - variant: { - id: variant.id, - scenario: variant.scenario.id, - title: variant.scenario.title, - area: variant.scenario.area, - viewport: variant.viewport, - theme: variant.theme, - path: variant.scenario.path, - }, - comparison, - baselineCapture: input.baselineResults.find((result) => result.variantId === variant.id), - candidateCapture: input.candidateResults.find((result) => result.variantId === variant.id), - } - }) - return { - run: input.run, - baseline: input.baseline, - candidate: input.candidate, - createdAt: new Date().toISOString(), - totals, - variants, - } -} - -/** Compact, agent-readable digest: worst variants first, top regions and deltas only. */ -export const renderMarkdown = (summary: RunSummary, runDir: string) => { - const lines = [ - `# UI parity: ${summary.candidate} vs ${summary.baseline} (${summary.run})`, - "", - `identical ${summary.totals.identical} · pass ${summary.totals.pass} · fail ${summary.totals.fail} · missing ${summary.totals.missing}`, - "", - ] - const behaviorOnly = summary.variants.filter( - ({ comparison }) => - comparison.behavior.length > 0 && - (comparison.visualStatus === "identical" || comparison.visualStatus === "pass"), - ) - const withBehavior = summary.variants.filter(({ comparison }) => comparison.behavior.length > 0) - if (withBehavior.length) - lines.push( - `behavior: ${withBehavior.length} variants send different backend calls (${behaviorOnly.length} of them identical or pass on pixels).`, - "", - ) - const ranked = [...summary.variants].sort( - (a, b) => b.comparison.perceptualPixels - a.comparison.perceptualPixels, - ) - for (const { variant, comparison, baselineCapture, candidateCapture } of ranked) { - if (comparison.status === "identical") continue - lines.push( - `## ${variant.id} — ${comparison.status.toUpperCase()} (${comparison.mismatchPercent}% / ${comparison.perceptualPixels}px)`, - ) - lines.push(`${variant.title} · \`${variant.path}\``) - for (const capture of [baselineCapture, candidateCapture]) { - if (capture?.error) lines.push(`- capture error: ${capture.error}`) - } - if (comparison.sizeMismatch) lines.push(`- page size differs`) - for (const line of comparison.behavior.slice(0, 10)) lines.push(`- ${line}`) - if (comparison.behavior.length > 10) - lines.push(`- …${comparison.behavior.length - 10} more call differences`) - lines.push( - `- images: ${relative(runDir, join(runDir, summary.baseline, `${variant.id}.png`))}, ${summary.candidate}/${variant.id}.png, diff/${comparison.diffImage}`, - ) - for (const region of comparison.regions.slice(0, 5)) { - lines.push( - `- region ${region.x},${region.y} ${region.width}×${region.height} (${region.pixels}px): ${region.nodes.join(", ") || "no structural match"}`, - ) - } - // Root causes first: identical style changes are grouped ("font-weight 600 → 500 on 9 text runs"), - // then elements that only moved (usually knock-on effects of the above). - const styleGroups = new Map() - for (const delta of comparison.deltas) { - if (!delta.styles.length) continue - const signature = delta.styles - .map((style) => `${style.prop}: ${style.baseline} → ${style.candidate}`) - .join("; ") - styleGroups.set(signature, [...(styleGroups.get(signature) ?? []), delta.label]) - } - for (const [signature, labels] of [...styleGroups] - .sort((a, b) => b[1].length - a[1].length) - .slice(0, 10)) { - lines.push( - ` - {${signature}} on ${labels.length}: ${labels.slice(0, 6).join(" | ")}${labels.length > 6 ? " …" : ""}`, - ) - } - const moves = comparison.deltas - .filter((delta) => !delta.styles.length) - .sort( - (a, b) => - Math.abs(b.moved.dx) + - Math.abs(b.moved.dy) + - Math.abs(b.moved.dw) + - Math.abs(b.moved.dh) - - (Math.abs(a.moved.dx) + - Math.abs(a.moved.dy) + - Math.abs(a.moved.dw) + - Math.abs(a.moved.dh)), - ) - for (const delta of moves.slice(0, 8)) { - const move = Object.entries(delta.moved) - .filter(([, v]) => Math.abs(v) > 0.5) - .map(([k, v]) => `${k}=${v}`) - .join(" ") - lines.push(` - moved: ${delta.label} [${move}]`) - } - if (moves.length > 8) lines.push(` - …${moves.length - 8} more moved elements`) - if (comparison.missingInCandidate.length) - lines.push( - ` - missing in ${summary.candidate}: ${comparison.missingInCandidate - .slice(0, 10) - .map((node) => node.label) - .join(" | ")}`, - ) - if (comparison.extraInCandidate.length) - lines.push( - ` - extra in ${summary.candidate}: ${comparison.extraInCandidate - .slice(0, 10) - .map((node) => node.label) - .join(" | ")}`, - ) - lines.push("") - } - lines.push(...renderA11y(summary)) - return lines.join("\n") -} - -/** - * ARIA snapshot differences, grouped by role across the run. Report-only unless `--strict-a11y`, - * so existing deltas can be triaged before they gate anything. - */ -const renderA11y = (summary: RunSummary) => { - const affected = summary.variants.filter( - ({ comparison }) => comparison.a11y.missing.length + comparison.a11y.extra.length > 0, - ) - if (!affected.length) return ["## a11y deltas", "", "None.", ""] - const groups = new Map; lines: Map }>() - for (const { variant, comparison } of affected) { - for (const [change, list] of [ - [`missing in ${summary.candidate}`, comparison.a11y.missing], - [`extra in ${summary.candidate}`, comparison.a11y.extra], - ] as const) { - for (const line of list) { - const key = `${change}: ${ariaRole(line)}` - const group = groups.get(key) ?? { variants: new Set(), lines: new Map() } - group.variants.add(variant.id) - group.lines.set(line, (group.lines.get(line) ?? 0) + 1) - groups.set(key, group) - } - } - } - const lines = [ - "## a11y deltas", - "", - `${affected.length} variants differ in their ARIA tree. Groups by role, most widespread first:`, - "", - ] - for (const [key, group] of [...groups] - .sort((a, b) => b[1].variants.size - a[1].variants.size) - .slice(0, 25)) { - const examples = [...group.lines] - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([line, count]) => `\`${line.slice(0, 80)}\` ×${count}`) - lines.push(`- ${key} in ${group.variants.size} variants: ${examples.join(", ")}`) - } - lines.push("", "Per variant (missing / extra lines):", "") - for (const { variant, comparison } of affected) - lines.push(`- ${variant.id}: -${comparison.a11y.missing.length} / +${comparison.a11y.extra.length}`) - lines.push("") - return lines -} - -export const writeReport = (summary: RunSummary, runDir: string) => { - writeFileSync(join(runDir, "summary.json"), JSON.stringify(summary, null, 2)) - writeFileSync(join(runDir, "summary.md"), renderMarkdown(summary, runDir)) - writeFileSync(join(runDir, "index.html"), renderHtml(summary)) - return join(runDir, "index.html") -} - -const renderHtml = (summary: RunSummary) => ` - - - - -UI Parity Report - - - -
-

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 - } - ] - } -} From c141b385d16d7737c9e9c6c49f376a6cdcf23af1 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:44:25 +0200 Subject: [PATCH 03/52] refactor(web): delete gallery-only kit components and dead exports ListBox, Table, Tree, Toggle, ToggleGroup, InputOTP, Text, SectionFooter, FormErrorSummary, FieldValidationState and aria/scroll were only reachable from the gallery. Calendar loses its Range mode (Single only), its formatters are built once, and unused exports, style keys and nine unused icons go too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/composer/update.ts | 2 +- apps/web-foldkit/src/composer/view.ts | 8 +- .../src/core/components/ui/calendar.styles.ts | 43 -- .../core/components/ui/command-menu.styles.ts | 6 - .../core/components/ui/context-menu.styles.ts | 2 - .../src/core/components/ui/dialog.styles.ts | 2 - .../ui/field-validation-state.styles.ts | 20 - .../src/core/components/ui/field.styles.ts | 7 - .../ui/form-error-summary.styles.ts | 17 - .../core/components/ui/input-otp.styles.ts | 14 - .../src/core/components/ui/list-box.styles.ts | 22 - .../core/components/ui/progress-bar.styles.ts | 2 - .../components/ui/section-footer.styles.ts | 7 - .../components/ui/section-label.styles.ts | 1 - .../src/core/components/ui/table.styles.ts | 89 ---- .../src/core/components/ui/text.styles.ts | 8 - .../core/components/ui/toggle-group.styles.ts | 105 ---- .../src/core/components/ui/toggle.styles.ts | 72 --- .../src/core/components/ui/tree.styles.ts | 31 -- apps/web-foldkit/src/icons/index.ts | 373 -------------- .../src/test/kit-collections-fixtures.ts | 12 - apps/web-foldkit/src/ui/aria/collection.ts | 6 - apps/web-foldkit/src/ui/aria/interaction.ts | 12 - apps/web-foldkit/src/ui/aria/scroll.ts | 56 --- apps/web-foldkit/src/ui/calendar-date.ts | 48 +- apps/web-foldkit/src/ui/calendar-view.ts | 67 +-- .../web-foldkit/src/ui/calendar.scene.test.ts | 44 +- .../web-foldkit/src/ui/calendar.story.test.ts | 107 +--- apps/web-foldkit/src/ui/calendar.ts | 118 +---- .../src/ui/field-validation-state.ts | 61 --- apps/web-foldkit/src/ui/field.ts | 25 - apps/web-foldkit/src/ui/form-error-summary.ts | 47 -- .../src/ui/input-otp.scene.test.ts | 115 ----- .../src/ui/input-otp.story.test.ts | 156 ------ apps/web-foldkit/src/ui/input-otp.ts | 460 ------------------ apps/web-foldkit/src/ui/list-box-view.ts | 215 -------- .../web-foldkit/src/ui/list-box.scene.test.ts | 126 ----- .../web-foldkit/src/ui/list-box.story.test.ts | 189 ------- apps/web-foldkit/src/ui/list-box.ts | 334 ------------- apps/web-foldkit/src/ui/menu-view.ts | 26 - apps/web-foldkit/src/ui/progress-bar.ts | 18 - apps/web-foldkit/src/ui/section-footer.ts | 28 -- apps/web-foldkit/src/ui/section-label.ts | 5 - apps/web-foldkit/src/ui/table-view.ts | 432 ---------------- apps/web-foldkit/src/ui/table.scene.test.ts | 133 ----- apps/web-foldkit/src/ui/table.story.test.ts | 153 ------ apps/web-foldkit/src/ui/table.ts | 266 ---------- apps/web-foldkit/src/ui/text.ts | 32 -- .../src/ui/toggle-group.scene.test.ts | 90 ---- .../src/ui/toggle-group.story.test.ts | 79 --- apps/web-foldkit/src/ui/toggle-group.ts | 201 -------- apps/web-foldkit/src/ui/toggle.scene.test.ts | 85 ---- apps/web-foldkit/src/ui/toggle.ts | 69 --- apps/web-foldkit/src/ui/tree-view.ts | 215 -------- apps/web-foldkit/src/ui/tree.scene.test.ts | 118 ----- apps/web-foldkit/src/ui/tree.story.test.ts | 94 ---- apps/web-foldkit/src/ui/tree.ts | 110 ----- 57 files changed, 44 insertions(+), 5139 deletions(-) delete mode 100644 apps/web-foldkit/src/core/components/ui/context-menu.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/field-validation-state.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/form-error-summary.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/input-otp.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/list-box.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/section-footer.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/table.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/text.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/toggle-group.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/toggle.styles.ts delete mode 100644 apps/web-foldkit/src/core/components/ui/tree.styles.ts delete mode 100644 apps/web-foldkit/src/test/kit-collections-fixtures.ts delete mode 100644 apps/web-foldkit/src/ui/aria/scroll.ts delete mode 100644 apps/web-foldkit/src/ui/field-validation-state.ts delete mode 100644 apps/web-foldkit/src/ui/form-error-summary.ts delete mode 100644 apps/web-foldkit/src/ui/input-otp.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/input-otp.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/input-otp.ts delete mode 100644 apps/web-foldkit/src/ui/list-box-view.ts delete mode 100644 apps/web-foldkit/src/ui/list-box.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/list-box.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/list-box.ts delete mode 100644 apps/web-foldkit/src/ui/section-footer.ts delete mode 100644 apps/web-foldkit/src/ui/table-view.ts delete mode 100644 apps/web-foldkit/src/ui/table.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/table.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/table.ts delete mode 100644 apps/web-foldkit/src/ui/text.ts delete mode 100644 apps/web-foldkit/src/ui/toggle-group.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/toggle-group.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/toggle-group.ts delete mode 100644 apps/web-foldkit/src/ui/toggle.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/toggle.ts delete mode 100644 apps/web-foldkit/src/ui/tree-view.ts delete mode 100644 apps/web-foldkit/src/ui/tree.scene.test.ts delete mode 100644 apps/web-foldkit/src/ui/tree.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/tree.ts diff --git a/apps/web-foldkit/src/composer/update.ts b/apps/web-foldkit/src/composer/update.ts index 9d0048e26..2791ca035 100644 --- a/apps/web-foldkit/src/composer/update.ts +++ b/apps/web-foldkit/src/composer/update.ts @@ -112,7 +112,7 @@ export const update = (model: Model, message: Message): Return => // 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 }), diff --git a/apps/web-foldkit/src/composer/view.ts b/apps/web-foldkit/src/composer/view.ts index a333a764b..77d6ddf0c 100644 --- a/apps/web-foldkit/src/composer/view.ts +++ b/apps/web-foldkit/src/composer/view.ts @@ -1,6 +1,5 @@ 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" @@ -18,7 +17,7 @@ const PICKER_BUTTON_CLASS = 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<M> { readonly toMessage: (message: Message) => M /** Attachment previews, reply and edit indicators, above the frame. */ @@ -152,8 +151,3 @@ export const composerBoxView = <M>(h: HtmlBuilder<M>, model: Model, extras: BoxE ), ], ) - -const identity = (message: Message) => message - -/** The bare composer (the gallery entry). */ -export const view = defineView<Model, Message>((model, h) => composerBoxView(h, model, { toMessage: identity })) 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..d841a6da5 100644 --- a/apps/web-foldkit/src/core/components/ui/calendar.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/calendar.styles.ts @@ -31,46 +31,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 `<span>` 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/command-menu.styles.ts b/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts index 521b4655c..39156deb1 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 @@ -36,7 +36,6 @@ export const commandMenuModalBase = (size: CommandMenuSize) => 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 +50,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/dialog.styles.ts b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts index b64d36eec..5c44f905d 100644 --- a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts @@ -2,8 +2,6 @@ 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" 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..ce5fb205b 100644 --- a/apps/web-foldkit/src/core/components/ui/field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/field.styles.ts @@ -29,13 +29,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/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/progress-bar.styles.ts b/apps/web-foldkit/src/core/components/ui/progress-bar.styles.ts index 133715db8..60c481ae6 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 @@ -10,8 +10,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/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-label.styles.ts b/apps/web-foldkit/src/core/components/ui/section-label.styles.ts index 789808b74..6a8c027b7 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 @@ -14,5 +14,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/table.styles.ts b/apps/web-foldkit/src/core/components/ui/table.styles.ts deleted file mode 100644 index 63166f878..000000000 --- a/apps/web-foldkit/src/core/components/ui/table.styles.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const tableStyles = { - root: "w-full min-w-full caption-bottom text-sm/6 outline-hidden [--table-selected-bg:var(--color-secondary)]/50", - scroller: - "relative -mx-(--gutter) overflow-x-auto whitespace-nowrap [--gutter-y:--spacing(2)] has-data-[slot=table-resizable-container]:overflow-auto", - inner: (bleed: boolean | undefined) => [ - "inline-block min-w-full align-middle", - !bleed && "sm:px-(--gutter)", - ], - columnResizer: - "absolute top-0 right-0 bottom-0 grid w-px &[data-resizable-direction=left]:cursor-e-resize &[data-resizable-direction=right]:cursor-w-resize touch-none place-content-center px-1 data-[resizable-direction=both]:cursor-ew-resize [&[data-resizing]>div]:bg-primary", - columnResizerLine: "h-full w-px bg-border py-(--gutter-y)", - column: ({ - bleed, - grid, - isResizable, - }: { - bleed: boolean | undefined - grid: boolean | undefined - isResizable: boolean - }) => [ - "text-left font-medium text-muted-fg", - "relative allows-sorting:cursor-default outline-hidden data-dragging:cursor-grabbing", - "px-4 py-(--gutter-y)", - "first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))", - !bleed && "sm:last:pr-1 sm:first:pl-1", - grid && "border-l first:border-l-0", - isResizable && "overflow-hidden truncate", - ], - columnContent: ["inline-flex items-center gap-2 **:data-[slot=icon]:shrink-0"], - sortIndicator: (isHovered: boolean) => [ - "grid size-[1.15rem] flex-none shrink-0 place-content-center rounded bg-secondary text-fg *:data-[slot=icon]:size-3.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:transition-transform *:data-[slot=icon]:duration-200", - isHovered ? "bg-secondary-fg/10" : "", - ], - sortIcon: (sortDirection: string | undefined) => (sortDirection === "ascending" ? "rotate-180" : ""), - header: "border-b", - utilityColumn: (bleed: boolean | undefined) => [ - "first:pl-(--gutter,--spacing(2))", - !bleed && "sm:last:pr-1 sm:first:pl-1", - ], - row: ({ - isSelected, - isFocusVisible, - isFocusVisibleWithin, - isDragging, - isDisabled, - isActionable, - striped, - }: { - isSelected: boolean - isFocusVisible: boolean - isFocusVisibleWithin: boolean - isDragging: boolean - isDisabled: boolean - isActionable: boolean - striped: boolean | undefined - }) => [ - "group relative cursor-default text-muted-fg outline outline-transparent", - isFocusVisible && "bg-primary/5 outline-primary ring-3 ring-ring/20 hover:bg-primary/10", - isDragging && "cursor-grabbing bg-primary/10 text-fg outline-primary", - isSelected && "bg-(--table-selected-bg) text-fg hover:bg-(--table-selected-bg)/50", - striped && "even:bg-muted", - isActionable && "hover:bg-(--table-selected-bg) hover:text-fg", - isActionable && - isFocusVisibleWithin && - "bg-(--table-selected-bg)/50 selected:bg-(--table-selected-bg)/50 text-fg", - isDisabled && "opacity-50", - ], - dragCell: "px-0", - dragButton: - "grid place-content-center rounded-xs px-[calc(var(--gutter)/2)] outline-hidden focus-visible:ring focus-visible:ring-ring", - cell: ({ - allowResize, - bleed, - grid, - striped, - }: { - allowResize: boolean | undefined - bleed: boolean | undefined - grid: boolean | undefined - striped: boolean | undefined - }) => [ - "group px-4 py-(--gutter-y) align-middle outline-hidden first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) group-has-data-focus-visible-within:text-fg", - !striped && "border-b", - grid && "border-l first:border-l-0", - !bleed && "sm:last:pr-1 sm:first:pl-1", - allowResize && "overflow-hidden truncate", - ], -} diff --git a/apps/web-foldkit/src/core/components/ui/text.styles.ts b/apps/web-foldkit/src/core/components/ui/text.styles.ts deleted file mode 100644 index c5da63835..000000000 --- a/apps/web-foldkit/src/core/components/ui/text.styles.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const textStyles = { - text: "text-base/6 text-muted-fg sm:text-sm/6", - textLink: - "text-primary-subtle-fg decoration-primary-subtle-fg/50 hover:underline hover:decoration-primary-subtle-fg", - strong: "font-medium", - code: "rounded-sm border bg-muted px-0.5 font-medium text-sm sm:text-[0.8125rem]", -} as const diff --git a/apps/web-foldkit/src/core/components/ui/toggle-group.styles.ts b/apps/web-foldkit/src/core/components/ui/toggle-group.styles.ts deleted file mode 100644 index f0de1cb86..000000000 --- a/apps/web-foldkit/src/core/components/ui/toggle-group.styles.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { tv } from "tailwind-variants" - -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const toggleGroupStyles = ({ - orientation, - selectionMode, - isCircle, -}: { - orientation?: "horizontal" | "vertical" - selectionMode?: "single" | "multiple" - isCircle?: boolean -}) => [ - "[--toggle-group-radius:var(--radius-lg)] [--toggle-gutter:--spacing(0.5)]", - "[--toggle-fg:var(--color-fg)] [--toggle-selected-bg:var(--color-primary)] [--toggle-selected-fg:var(--color-primary-fg)]", - "[--toggle-focused-bg:var(--color-secondary)] [--toggle-focused-fg:var(--color-secondary-fg)]", - "[--toggle-hover-bg:var(--toggle-focused-bg)] [--toggle-hover-fg:var(--toggle-focused-fg)]", - "[--toggle-icon:color-mix(in_oklab,var(--toggle-focused-fg)_50%,var(--toggle-focused-bg))]", - "inset-ring inset-ring-border inline-flex overflow-hidden p-(--toggle-gutter)", - orientation === "horizontal" ? "flex-row" : "flex-col", - selectionMode === "single" ? "gap-(--toggle-gutter)" : "gap-0", - isCircle ? "rounded-full" : "rounded-(--toggle-group-radius)", - selectionMode === "single" && isCircle && "*:data-[slot=toggle-group-item]:rounded-full", - selectionMode === "multiple" && - isCircle && - "*:data-[slot=toggle-group-item]:last:rounded-r-full *:data-[slot=toggle-group-item]:first:rounded-l-full", -] - -export const toggleGroupItemStyles = tv({ - base: [ - "relative isolate", - "inline-flex flex-row items-center font-medium text-(--toggle-fg) outline-hidden", - "inset-ring inset-ring-transparent", - "*: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]", - ], - variants: { - orientation: { - horizontal: "justify-center", - vertical: "justify-start", - }, - selectionMode: { - single: "rounded-[calc(var(--toggle-group-radius)-var(--toggle-gutter))]", - multiple: "rounded-none", - }, - size: { - xs: [ - "min-h-8 gap-x-1.5 px-2.5 py-1.5 text-sm sm:min-h-7 sm:px-2 sm:py-1.5 sm:text-xs/4", - "*:data-[slot=icon]:-mx-px *:data-[slot=icon]:size-3.5 sm:*:data-[slot=icon]:size-3", - "*:data-[slot=loader]:-mx-px *:data-[slot=loader]:size-3.5 sm:*:data-[slot=loader]:size-3", - ], - sm: [ - "min-h-9 gap-x-1.5 px-3 py-1.5 sm:min-h-8 sm:px-2.5 sm:py-1.5 sm:text-sm/5", - "*:data-[slot=icon]:size-4.5 sm:*:data-[slot=icon]:size-4", - "*:data-[slot=loader]:size-4.5 sm:*:data-[slot=loader]:size-4", - ], - md: [ - "min-h-10 gap-x-2 px-3.5 py-2 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-sm/6", - "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4", - "*:data-[slot=loader]:size-5 sm:*:data-[slot=loader]:size-4", - ], - lg: [ - "min-h-11 gap-x-2 px-4 py-2.5 sm:min-h-10 sm:px-3.5 sm:py-2 sm:text-sm/6", - "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4.5", - "*:data-[slot=loader]:size-5 sm:*:data-[slot=loader]:size-4.5", - ], - "sq-xs": - "touch-target size-8 *:data-[slot=icon]:size-3.5 *:data-[slot=loader]:size-3.5 sm:size-7 sm:*:data-[slot=icon]:size-3 sm:*:data-[slot=loader]:size-3", - "sq-sm": - "touch-target size-9 *:data-[slot=icon]:size-4.5 *:data-[slot=loader]:size-4.5 sm:size-8 sm:*:data-[slot=icon]:size-4 sm:*:data-[slot=loader]:size-4", - "sq-md": - "touch-target size-10 *:data-[slot=icon]:size-5 *:data-[slot=loader]:size-5 sm:size-9 sm:*:data-[slot=icon]:size-4.5 sm:*:data-[slot=loader]:size-4.5", - "sq-lg": - "touch-target size-11 *:data-[slot=icon]:size-5 *:data-[slot=loader]:size-5 sm:size-10 sm:*:data-[slot=icon]:size-5 sm:*:data-[slot=loader]:size-5", - }, - isSelected: { - true: "inset-ring-fg/20 bg-(--toggle-selected-bg) text-(--toggle-selected-fg) [--toggle-icon:var(--primary-fg)] hover:bg-(--toggle-selected-bg)/90", - }, - isFocused: { - true: "not-selected:bg-(--toggle-focused-bg) not-selected:text-(--toggle-focused-fg) not-selected:[--toggle-icon:var(--toggle-focused-fg)]", - }, - isHovered: { - true: "enabled:not-selected:bg-(--toggle-hover-bg) enabled:not-selected:text-(--toggle-hover-fg) enabled:not-selected:[--toggle-icon:var(--toggle-hover-fg)]", - }, - isDisabled: { - true: "opacity-50 forced-colors:text-[GrayText]", - }, - }, - defaultVariants: { - size: "md", - }, - compoundVariants: [ - { - selectionMode: "multiple", - orientation: "horizontal", - className: - "not-first:-ml-px first:rounded-l-[calc(var(--toggle-group-radius)-var(--toggle-gutter))] last:rounded-r-[calc(var(--toggle-group-radius)-var(--toggle-gutter))]", - }, - { - selectionMode: "multiple", - orientation: "vertical", - className: - "not-first:-mt-px first:rounded-t-[calc(var(--toggle-group-radius)-var(--toggle-gutter))] last:rounded-b-[calc(var(--toggle-group-radius)-var(--toggle-gutter))]", - }, - ], -}) diff --git a/apps/web-foldkit/src/core/components/ui/toggle.styles.ts b/apps/web-foldkit/src/core/components/ui/toggle.styles.ts deleted file mode 100644 index fc6c8dce1..000000000 --- a/apps/web-foldkit/src/core/components/ui/toggle.styles.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { tv } from "tailwind-variants" - -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const toggleStyles = tv({ - base: [ - "[--toggle-icon-active:var(--secondary-fg)] [--toggle-icon:color-mix(in_oklab,var(--secondary-fg)_50%,var(--secondary))]", - "relative inset-ring inset-ring-fg/15 isolate inline-flex items-center justify-center font-medium", - "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]:my-0.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--toggle-icon) sm:*:data-[slot=icon]:my-1", - "focus-visible:*:data-[slot=icon]:text-(--toggle-icon-active)", - "selected:*:data-[slot=icon]:text-(--toggle-icon-active)", - "hover:*:data-[slot=icon]:text-(--toggle-icon-active)", - "*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--toggle-icon) pressed:*:data-[slot=icon]:text-(--toggle-icon-active) focus-visible:*:data-[slot=icon]:text-(--toggle-icon-active)/80 hover:*:data-[slot=icon]:text-(--toggle-icon-active)/90 forced-colors:[--toggle-icon:ButtonText] forced-colors:hover:[--toggle-icon:ButtonText]", - "forced-colors:[--toggle-icon:ButtonText] forced-colors:hover:[--toggle-icon:ButtonText]", - ], - variants: { - intent: { - outline: [ - "bg-transparent selected:bg-secondary outline-secondary-fg ring-secondary-fg/25 hover:bg-secondary", - ], - plain: [ - "inset-ring-transparent bg-transparent selected:bg-secondary outline-secondary-fg ring-secondary-fg/25 hover:bg-secondary", - ], - }, - size: { - xs: [ - "min-h-8 gap-x-1.5 px-[calc(--spacing(3)-1px)] py-[calc(--spacing(1.5)-1px)] text-sm sm:min-h-7 sm:px-2 sm:py-[calc(--spacing(1.5)-1px)] sm:text-xs/4", - "*:data-[slot=icon]:-mx-px *:data-[slot=icon]:size-3.5 sm:*:data-[slot=icon]:size-3", - ], - sm: [ - "min-h-9 gap-x-1.5 px-3 py-[calc(--spacing(2)-1px)] sm:min-h-8 sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/5", - "*:data-[slot=icon]:size-4.5 sm:*:data-[slot=icon]:size-4", - ], - md: [ - "min-h-10 gap-x-2 px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:min-h-9 sm:px-3 sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6", - "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4", - ], - lg: [ - "min-h-10 gap-x-2 px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(3)-1px)] sm:min-h-9 sm:px-3 sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/7", - "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4.5", - ], - "sq-xs": [ - "touch-target size-8 sm:size-7", - "*:data-[slot=icon]:size-3.5 sm:*:data-[slot=icon]:size-3", - ], - "sq-sm": [ - "touch-target size-10 sm:size-8", - "*:data-[slot=icon]:size-4.5 sm:*:data-[slot=icon]:size-4", - ], - "sq-md": [ - "touch-target size-11 sm:size-9", - "*:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4.5", - ], - "sq-lg": [ - "touch-target size-12 sm:size-10", - "*:data-[slot=icon]:size-6 sm:*:data-[slot=icon]:size-5", - ], - }, - isCircle: { - true: "rounded-full", - false: "rounded-[calc(var(--radius-lg)-1px)]", - }, - isDisabled: { - true: "inset-ring-0 opacity-50 forced-colors:text-[GrayText]", - }, - }, - defaultVariants: { - intent: "plain", - size: "md", - isCircle: false, - }, -}) diff --git a/apps/web-foldkit/src/core/components/ui/tree.styles.ts b/apps/web-foldkit/src/core/components/ui/tree.styles.ts deleted file mode 100644 index 81738999c..000000000 --- a/apps/web-foldkit/src/core/components/ui/tree.styles.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const treeStyles = { - tree: [ - "flex cursor-default flex-col gap-y-2 overflow-auto outline-hidden forced-color-adjust-none", - "[--tree-active-bg:var(--color-primary-subtle)] [--tree-active-fg:var(--color-primary-subtle-fg)]", - ], - item: (isLink: boolean) => [ - "shrink-0 rounded-lg px-2 py-1.5 pr-2", - "group/tree-item relative flex select-none rounded-lg focus:outline-hidden", - "focus:bg-(--tree-active-bg) focus:text-(--tree-active-fg) focus:**:[.text-muted-fg]:text-(--tree-active-fg)", - "**:data-[slot=avatar]:*:size-6 **:data-[slot=avatar]:size-6 sm:**:data-[slot=avatar]:*:size-5 sm:**:data-[slot=avatar]:size-5", - "**:data-[slot=icon]:mr-1 **:data-[slot=icon]:size-5 **:data-[slot=icon]:shrink-0 sm:**:data-[slot=icon]:size-4", - "disabled:opacity-50 forced-colors:[", - isLink ? "cursor-pointer" : "cursor-default", - ], - content: "relative flex w-full min-w-0 items-center gap-x-1 truncate text-sm/6", - selectionCheckbox: "[--indicator-mt:0] sm:[--indicator-mt:0]", - levelGuide: [ - "relative w-[calc(calc(var(--tree-item-level)-1)*calc(var(--spacing)*5))] shrink-0", - "before:absolute before:inset-0 before:-ms-1 before:bg-[repeating-linear-gradient(to_right,transparent_0,transparent_calc(var(--tree-item-level)-1px),var(--border)_calc(var(--tree-item-level)-1px),var(--border)_calc(var(--tree-item-level)))]", - ], - leafSpacer: "block w-5 shrink-0", - indicator: (isExpanded: boolean | undefined) => [ - "shrink-0 content-center text-muted-fg hover:text-fg", - isExpanded && "text-fg", - ], - chevron: (isExpanded: boolean | undefined) => [ - "size-4 transition-transform duration-200 ease-in-out sm:size-5", - isExpanded && "rotate-90", - ], -} diff --git a/apps/web-foldkit/src/icons/index.ts b/apps/web-foldkit/src/icons/index.ts index 44578b8cd..fe5df2d2c 100644 --- a/apps/web-foldkit/src/icons/index.ts +++ b/apps/web-foldkit/src/icons/index.ts @@ -98,59 +98,6 @@ export const IconArrowPath = <Message>(h: HtmlBuilder<Message>, options: IconOpt ], ) -export const IconBellSlash = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "bell-slash"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M10.2 15H7.80099C7.64999 15 7.50799 15.068 7.41299 15.185C7.31799 15.302 7.28099 15.456 7.31199 15.603C7.48499 16.425 8.17999 17 9.00099 17C9.82199 17 10.517 16.425 10.69 15.603C10.721 15.456 10.684 15.302 10.589 15.185C10.494 15.068 10.351 15 10.2 15Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M13.9335 4.06644C13.0355 2.24966 11.1635 1 9 1C5.96279 1 3.5 3.46279 3.5 6.5V10.75C3.5 11.4408 2.94079 12 2.25 12C1.83579 12 1.5 12.3358 1.5 12.75C1.5 13.1642 1.83579 13.5 2.25 13.5H4.49999L13.9335 4.06644Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("opacity", "0.4"), - ]), - h.path([ - h.Attribute( - "d", - "M14.5 6.68198L7.68198 13.5H15.75C16.1642 13.5 16.5 13.1642 16.5 12.75C16.5 12.3358 16.1642 12 15.75 12C15.0592 12 14.5 11.4408 14.5 10.75V6.68198Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("opacity", "0.4"), - ]), - h.path([ - h.Attribute("fill-rule", "evenodd"), - h.Attribute("clip-rule", "evenodd"), - h.Attribute( - "d", - "M16.5303 1.46967C16.8232 1.76256 16.8232 2.23744 16.5303 2.53033L2.53033 16.5303C2.23744 16.8232 1.76256 16.8232 1.46967 16.5303C1.17678 16.2374 1.17678 15.7626 1.46967 15.4697L15.4697 1.46967C15.7626 1.17678 16.2374 1.17678 16.5303 1.46967Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconBell = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -235,74 +182,6 @@ export const IconBrainSparkle = <Message>(h: HtmlBuilder<Message>, options: Icon ], ) -export const IconBranch = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "branch"]), - h.path([ - h.Attribute("d", "M5 3 V12 H13"), - h.Attribute("fill", "none"), - h.Attribute("stroke", "currentColor"), - h.Attribute("stroke-width", "2"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - ], - ) - -export const IconChangelog = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "badge 13"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M15.6856 7.67326L9.8165 4.58299C9.3058 4.31299 8.69539 4.31251 8.18469 4.58251L2.3156 7.67326V7.6743C1.8215 7.9346 1.5148 8.44289 1.5148 9.00139C1.5148 9.55989 1.8214 10.0683 2.3156 10.3285L8.18381 13.4179C8.43971 13.5532 8.7199 13.6205 9.0007 13.6205C9.2805 13.6205 9.5608 13.5531 9.8156 13.4184L15.6857 10.3276C16.1798 10.0673 16.4865 9.55897 16.4865 9.00047C16.4865 8.44197 16.1798 7.93346 15.6856 7.67326Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("opacity", "0.4"), - ]), - h.path([ - h.Attribute( - "d", - "M15.6856 10.9233L15.1199 10.6254L9.81552 13.4184C9.56062 13.5532 9.28031 13.6205 9.00061 13.6205C8.71981 13.6205 8.43962 13.5531 8.18372 13.4179L2.88031 10.6259L2.31552 10.9233V10.9243C1.82142 11.1846 1.51471 11.6929 1.51471 12.2514C1.51471 12.8099 1.82132 13.3183 2.31552 13.5785L8.18372 16.6679C8.43962 16.8032 8.71981 16.8705 9.00061 16.8705C9.28041 16.8705 9.56072 16.8031 9.81552 16.6684L15.6856 13.5776C16.1797 13.3173 16.4864 12.809 16.4864 12.2505C16.4864 11.692 16.1798 11.1835 15.6856 10.9233Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("opacity", "0.2"), - ]), - h.path([ - h.Attribute( - "d", - "M15.6856 4.42241L9.8165 1.33208C9.3058 1.06208 8.69539 1.06159 8.18469 1.33159L2.3156 4.42241C1.8215 4.68271 1.5148 5.19197 1.5148 5.75047C1.5148 6.30897 1.8214 6.81736 2.3156 7.07756L8.18381 10.167C8.43971 10.3023 8.7199 10.3696 9.0007 10.3696C9.2805 10.3696 9.5608 10.3022 9.8156 10.1675L15.6857 7.07671C16.1798 6.81641 16.4865 6.30806 16.4865 5.74956C16.4865 5.19106 16.1798 4.68261 15.6856 4.42241Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconChartBar = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -540,34 +419,6 @@ export const IconChevronUpDown = <Message>(h: HtmlBuilder<Message>, options: Ico ], ) -export const IconChevronUp = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "12", - width: "12", - "data-slot": "icon", - viewBox: "0 0 12 12", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "chevron-up"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "m10.25,8.5c-.192,0-.384-.073-.53-.22l-3.72-3.72-3.72,3.72c-.293.293-.768.293-1.061,0s-.293-.768,0-1.061L5.47,2.97c.293-.293.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconCircleDottedUser = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -1603,42 +1454,6 @@ export const IconFolderPlus = <Message>(h: HtmlBuilder<Message>, options: IconOp ], ) -export const IconFolder = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "folder"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M1.5 6.5H16.5V13.25C16.5 14.7692 15.2692 16 13.75 16H4.25C2.73079 16 1.5 14.7692 1.5 13.25V6.5Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("opacity", "0.4"), - ]), - h.path([ - h.Attribute( - "d", - "M4.25 2C2.73079 2 1.5 3.23079 1.5 4.75V6.5H16.5V6.25C16.5 4.73079 15.2692 3.5 13.75 3.5H8.72395L8.34569 3.02827C7.82347 2.37825 7.03552 2 6.201 2H4.25Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconFolders = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -2284,64 +2099,6 @@ export const IconLogout = <Message>(h: HtmlBuilder<Message>, options: IconOption ], ) -export const IconMagicWandSparkle = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "badge 13"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M4.49298 4.74201L3.54698 4.42701L3.23098 3.47999C3.12898 3.17399 2.62198 3.17399 2.51998 3.47999L2.20398 4.42701L1.25798 4.74201C1.10498 4.79301 1.00098 4.93603 1.00098 5.09803C1.00098 5.26003 1.10498 5.40299 1.25798 5.45399L2.20398 5.76899L2.51998 6.71601C2.57098 6.86901 2.71398 6.97199 2.87498 6.97199C3.03598 6.97199 3.17998 6.86801 3.22998 6.71601L3.54598 5.76899L4.49198 5.45399C4.64498 5.40299 4.74898 5.26003 4.74898 5.09803C4.74898 4.93603 4.64598 4.79301 4.49298 4.74201Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M16.658 12.99L15.395 12.5689L14.974 11.3061C14.837 10.8981 14.162 10.8981 14.025 11.3061L13.604 12.5689L12.341 12.99C12.137 13.058 11.999 13.249 11.999 13.464C11.999 13.679 12.137 13.87 12.341 13.938L13.604 14.359L14.025 15.622C14.093 15.826 14.285 15.964 14.5 15.964C14.715 15.964 14.906 15.826 14.975 15.622L15.396 14.359L16.659 13.938C16.863 13.87 17.001 13.679 17.001 13.464C17.001 13.249 16.862 13.058 16.658 12.99Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M5.75 2.5C6.1642 2.5 6.5 2.164 6.5 1.75C6.5 1.336 6.1642 1 5.75 1C5.3358 1 5 1.336 5 1.75C5 2.164 5.3358 2.5 5.75 2.5Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M11.1406 6.85936C11.4335 7.15225 11.4335 7.62712 11.1406 7.92002L3.03033 16.0303C2.73744 16.3232 2.26257 16.3232 1.96967 16.0303C1.67678 15.7374 1.67678 15.2626 1.96967 14.9697L10.0799 6.85936C10.3728 6.56647 10.8477 6.56646 11.1406 6.85936Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("fill-opacity", "0.4"), - h.Attribute("fill-rule", "evenodd"), - ]), - h.path([ - h.Attribute( - "d", - "M14.8106 5.07203L15.4366 2.75703C15.5116 2.48203 15.4226 2.18803 15.2086 1.99903C14.9946 1.81003 14.6906 1.75903 14.4276 1.86803L12.2086 2.77903L10.1996 1.46703C9.96159 1.31103 9.65259 1.30403 9.40759 1.44903C9.16159 1.59403 9.01959 1.86603 9.04159 2.15103L9.22359 4.54303L7.35559 6.04803C7.13259 6.22703 7.03259 6.51703 7.09359 6.79603C7.15559 7.07503 7.37059 7.29402 7.64859 7.36102L9.97959 7.92803L10.8336 10.169C10.9356 10.436 11.1796 10.622 11.4646 10.648C11.4876 10.65 11.5116 10.651 11.5346 10.651C11.7936 10.651 12.0356 10.518 12.1736 10.295L13.4326 8.25402L15.8286 8.13403C16.1136 8.12003 16.3666 7.94502 16.4796 7.68302C16.5926 7.42102 16.5476 7.11802 16.3636 6.90002L14.8106 5.07203Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconMagnifier3 = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -2587,42 +2344,6 @@ export const IconMoon = <Message>(h: HtmlBuilder<Message>, options: IconOptions ], ) -export const IconMsgClock = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "badge 13"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M14 10C11.7939 10 10 11.7944 10 14C10 16.2056 11.7939 18 14 18C16.2061 18 18 16.2056 18 14C18 11.7944 16.2061 10 14 10ZM16.3125 14.9502C16.1934 15.2398 15.9141 15.415 15.6191 15.415C15.5234 15.415 15.4277 15.3969 15.3339 15.3588L13.7148 14.6938C13.4336 14.5781 13.25 14.3042 13.25 14V12.25C13.25 11.8359 13.5859 11.5 14 11.5C14.4141 11.5 14.75 11.8359 14.75 12.25V13.4971L15.9043 13.9712C16.2871 14.1284 16.4707 14.5669 16.3125 14.9502Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M1.00002 9C1.00002 4.58179 4.58181 1 9.00002 1C13.4182 1 17 4.58179 17 9C17 9.12917 16.997 9.25763 16.9909 9.38531C16.1297 8.82544 15.1024 8.5 14 8.5C10.9654 8.5 8.5 10.9661 8.5 14C8.5 15.1023 8.82542 16.1296 9.38532 16.9909C9.25765 16.9969 9.12919 17 9.00002 17C8.49599 17 8.00127 16.9526 7.52247 16.8619C6.63844 16.6947 5.91986 16.4026 5.39182 16.1345C4.98601 16.3421 4.4872 16.5366 3.96902 16.6876C3.26319 16.8932 2.44913 17.0391 1.70928 16.9989C1.41385 16.9828 1.15547 16.7946 1.04964 16.5183C0.943812 16.242 1.01031 15.9293 1.2194 15.72C1.55932 15.3797 1.85824 14.8052 2.01741 14.1997C2.09518 13.9039 2.13238 13.6268 2.13177 13.3972C2.13116 13.1672 2.09295 13.0343 2.06488 12.977C1.39246 11.8068 1.00002 10.4506 1.00002 9Z", - ), - h.Attribute("fill", "currentColor"), - h.Attribute("fill-opacity", "0.4"), - ]), - ], - ), - ], - ) - export const IconMsgs = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -3375,64 +3096,6 @@ export const IconSparkles = <Message>(h: HtmlBuilder<Message>, options: IconOpti ], ) -export const IconSquareCommand = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "command"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute("fill-rule", "evenodd"), - h.Attribute("clip-rule", "evenodd"), - h.Attribute( - "d", - "M4.75 3.5C4.06021 3.5 3.5 4.06021 3.5 4.75C3.5 5.43979 4.06021 6 4.75 6H6V4.75C6 4.06021 5.43979 3.5 4.75 3.5ZM2 4.75C2 3.23179 3.23179 2 4.75 2C6.26821 2 7.5 3.23179 7.5 4.75V6H10.5V4.75C10.5 3.23179 11.7318 2 13.25 2C14.7682 2 16 3.23179 16 4.75C16 6.26821 14.7682 7.5 13.25 7.5H12V10.5H13.25C14.7682 10.5 16 11.7318 16 13.25C16 14.7682 14.7682 16 13.25 16C11.7318 16 10.5 14.7682 10.5 13.25V12H7.5V13.25C7.5 14.7682 6.26821 16 4.75 16C3.23179 16 2 14.7682 2 13.25C2 11.7318 3.23179 10.5 4.75 10.5H6V7.5H4.75C3.23179 7.5 2 6.26821 2 4.75ZM7.5 7.5V10.5H10.5V7.5H7.5ZM12 4.75C12 4.06021 12.5602 3.5 13.25 3.5C13.9398 3.5 14.5 4.06021 14.5 4.75C14.5 5.43979 13.9398 6 13.25 6H12V4.75ZM12 12V13.25C12 13.9398 12.5602 14.5 13.25 14.5C13.9398 14.5 14.5 13.9398 14.5 13.25C14.5 12.5602 13.9398 12 13.25 12H12ZM3.5 13.25C3.5 12.5602 4.06021 12 4.75 12H6V13.25C6 13.9398 5.43979 14.5 4.75 14.5C4.06021 14.5 3.5 13.9398 3.5 13.25Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - -export const IconSquareTerminal = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "square terminal"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute( - "d", - "M13.25,2H4.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75ZM6.28,12.78c-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22c-.293-.293-.293-.768,0-1.061l1.97-1.97-1.97-1.97c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l2.5,2.5c.293,.293,.293,.768,0,1.061l-2.5,2.5Zm5.97,.22h-2.5c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75h2.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const IconStar = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, @@ -4124,42 +3787,6 @@ export const IconWebhook = <Message>(h: HtmlBuilder<Message>, options: IconOptio ], ) -export const IconXmark = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => - icon( - h, - { - height: "18", - width: "18", - "data-slot": "icon", - viewBox: "0 0 18 18", - xmlns: "http://www.w3.org/2000/svg", - }, - options, - [ - h.title([], [options.title ?? "xmark"]), - h.g( - [h.Attribute("fill", "currentColor")], - [ - h.path([ - h.Attribute("opacity", "0.4"), - h.Attribute( - "d", - "M14.0001 14.75C13.8082 14.75 13.6163 14.6768 13.4698 14.5303L3.46981 4.53026C3.17681 4.23726 3.17681 3.76273 3.46981 3.46983C3.76281 3.17693 4.2374 3.17683 4.5303 3.46983L14.5303 13.4698C14.8233 13.7628 14.8233 14.2374 14.5303 14.5303C14.3838 14.6768 14.192 14.75 14.0001 14.75Z", - ), - h.Attribute("fill", "currentColor"), - ]), - h.path([ - h.Attribute( - "d", - "M4.00011 14.75C3.80821 14.75 3.61631 14.6768 3.46981 14.5303C3.17681 14.2373 3.17681 13.7628 3.46981 13.4699L13.4698 3.46987C13.7628 3.17687 14.2374 3.17687 14.5303 3.46987C14.8232 3.76287 14.8233 4.2374 14.5303 4.5303L4.5303 14.5303C4.3838 14.6768 4.19201 14.75 4.00011 14.75Z", - ), - h.Attribute("fill", "currentColor"), - ]), - ], - ), - ], - ) - export const Logo = <Message>(h: HtmlBuilder<Message>, options: IconOptions = {}): Html => icon( h, diff --git a/apps/web-foldkit/src/test/kit-collections-fixtures.ts b/apps/web-foldkit/src/test/kit-collections-fixtures.ts deleted file mode 100644 index f94869d64..000000000 --- a/apps/web-foldkit/src/test/kit-collections-fixtures.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * jsdom ships no `CSS.escape`, but the collection views build focus selectors with it while - * handling keydown. Installs a minimal escape once per test file. - */ -export const installCssEscape = (): void => { - if (typeof CSS === "undefined" || typeof CSS.escape !== "function") { - Object.defineProperty(globalThis, "CSS", { - configurable: true, - value: { escape: (value: string) => value.replace(/([^\w-])/g, "\\$1") }, - }) - } -} diff --git a/apps/web-foldkit/src/ui/aria/collection.ts b/apps/web-foldkit/src/ui/aria/collection.ts index 702844998..bbacdb932 100644 --- a/apps/web-foldkit/src/ui/aria/collection.ts +++ b/apps/web-foldkit/src/ui/aria/collection.ts @@ -69,9 +69,6 @@ export const itemState = (interaction: Interaction, key: string, isDisabled = fa export const isWithinFocused = (interaction: Interaction) => Option.isSome(interaction.maybeFocusedKey) -export const isWithinFocusVisible = (interaction: Interaction) => - isWithinFocused(interaction) && interaction.modality !== "Pointer" - // ATTRIBUTES const flag = <Message>(h: HtmlBuilder<Message>, name: string, isOn: boolean | undefined) => @@ -154,6 +151,3 @@ export const idSelector = (id: string) => `#${CSS.escape(id)}` */ export const idAttributeSelector = (id: string) => `[id="${id.replace(/["\\]/g, "\\$&")}"]` -/** Selects the item at `index` inside a labelled composite, for widgets whose items carry no id. */ -export const labelledChildSelector = (label: string, index: number) => - `[aria-label="${CSS.escape(label)}"] > :nth-child(${index + 1})` diff --git a/apps/web-foldkit/src/ui/aria/interaction.ts b/apps/web-foldkit/src/ui/aria/interaction.ts index eee53408d..c35259ea4 100644 --- a/apps/web-foldkit/src/ui/aria/interaction.ts +++ b/apps/web-foldkit/src/ui/aria/interaction.ts @@ -410,15 +410,3 @@ export const pressStyleAttributes = <ParentMessage>( ? [h.Attribute("style", "user-select: none;")] : [] -/** Everything a React Aria pressable writes for one target: `data-rac`, handlers and state. */ -export const targetAttributes = <ParentMessage>( - h: HtmlBuilder<ParentMessage>, - wiring: Wiring<ParentMessage>, - target: string, - options: TargetOptions = {}, -): ReadonlyArray<Attribute<ParentMessage>> => [ - h.DataAttribute("rac", ""), - ...handlers(h, wiring, target, options), - ...stateAttributes(h, stateOf(wiring.model, target)), - ...pressStyleAttributes(h, wiring.model, target), -] diff --git a/apps/web-foldkit/src/ui/aria/scroll.ts b/apps/web-foldkit/src/ui/aria/scroll.ts deleted file mode 100644 index 44c2e14da..000000000 --- a/apps/web-foldkit/src/ui/aria/scroll.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * React Aria's `scrollIntoView` (@react-aria/utils, Apache-2.0) with `block` and `inline` at - * `nearest`: scrolls `scrollView` so `element` is visible, honoring scroll padding and margins, - * without touching ancestors above `scrollView`. - */ -export const scrollIntoView = (scrollView: HTMLElement, element: HTMLElement): void => { - if (scrollView === element) return - let y = scrollView.scrollTop - let x = scrollView.scrollLeft - const target = element.getBoundingClientRect() - const view = scrollView.getBoundingClientRect() - const itemStyle = window.getComputedStyle(element) - const viewStyle = window.getComputedStyle(scrollView) - const root = document.scrollingElement ?? document.documentElement - const isRoot = scrollView === root - const px = (value: string) => Number.parseInt(value, 10) || 0 - - const viewTop = isRoot ? 0 : view.top - const viewBottom = isRoot ? scrollView.clientHeight : view.bottom - const viewLeft = isRoot ? 0 : view.left - const viewRight = isRoot ? scrollView.clientWidth : view.right - const borderTop = px(viewStyle.borderTopWidth) - const borderBottom = px(viewStyle.borderBottomWidth) - const borderLeft = px(viewStyle.borderLeftWidth) - const borderRight = px(viewStyle.borderRightWidth) - - const areaTop = target.top - px(itemStyle.scrollMarginTop) - const areaBottom = target.bottom + px(itemStyle.scrollMarginBottom) - const areaLeft = target.left - px(itemStyle.scrollMarginLeft) - const areaRight = target.right + px(itemStyle.scrollMarginRight) - - const scrollBarWidth = - scrollView.offsetWidth - scrollView.clientWidth - (isRoot ? 0 : borderLeft + borderRight) - const scrollBarHeight = - scrollView.offsetHeight - scrollView.clientHeight - (isRoot ? 0 : borderTop + borderBottom) - const portTop = viewTop + borderTop + px(viewStyle.scrollPaddingTop) - const portBottom = viewBottom - borderBottom - px(viewStyle.scrollPaddingBottom) - scrollBarHeight - const portLeft = viewLeft + borderLeft + px(viewStyle.scrollPaddingLeft) - const portRight = - viewRight - - borderRight - - px(viewStyle.scrollPaddingRight) - - (viewStyle.direction === "rtl" ? 0 : scrollBarWidth) - - if (areaTop < portTop || areaBottom > portBottom) { - const start = areaTop - portTop - const end = areaBottom - portBottom - y += Math.abs(start) <= Math.abs(end) ? start : end - } - if (areaLeft < portLeft || areaRight > portRight) { - const start = areaLeft - portLeft - const end = areaRight - portRight - x += Math.abs(start) <= Math.abs(end) ? start : end - } - scrollView.scrollTo({ left: x, top: y }) -} diff --git a/apps/web-foldkit/src/ui/calendar-date.ts b/apps/web-foldkit/src/ui/calendar-date.ts index db951d7bd..a25044ab7 100644 --- a/apps/web-foldkit/src/ui/calendar-date.ts +++ b/apps/web-foldkit/src/ui/calendar-date.ts @@ -13,12 +13,6 @@ export const toDate = (date: CalendarDate): Date => new Date(`${date}T00:00:00.0 export const fromDate = (date: Date): CalendarDate => `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` -/** `today(getLocalTimeZone())`. */ -// The default mirrors React Aria's today(); only the gallery entries rely on it, when seeding their init. -// oxlint-disable-next-line foldkit/no-impure-call-at-decision-time -export const today = (now: Date = new Date()): CalendarDate => - `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` - export const parts = (date: CalendarDate) => { const value = toDate(date) return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate() } @@ -79,33 +73,19 @@ const fullDate = formatter({ weekday: "long", month: "long", day: "numeric", yea /** The cell's accessible date, "Thursday, March 12, 2026". */ export const formatFull = (date: CalendarDate) => fullDate.format(toDate(date)) -export const formatMonthYear = (date: CalendarDate) => - formatter({ month: "long", year: "numeric" }).format(toDate(date)) -export const formatShortMonth = (date: CalendarDate) => formatter({ month: "short" }).format(toDate(date)) -export const formatYear = (date: CalendarDate) => formatter({ year: "numeric" }).format(toDate(date)) -export const formatDay = (date: CalendarDate) => formatter({ day: "numeric" }).format(toDate(date)) +const monthYear = formatter({ month: "long", year: "numeric" }) +const shortMonth = formatter({ month: "short" }) +const year = formatter({ year: "numeric" }) +const day = formatter({ day: "numeric" }) +export const formatMonthYear = (date: CalendarDate) => monthYear.format(toDate(date)) +export const formatShortMonth = (date: CalendarDate) => shortMonth.format(toDate(date)) +export const formatYear = (date: CalendarDate) => year.format(toDate(date)) +export const formatDay = (date: CalendarDate) => day.format(toDate(date)) + +const narrowWeekday = formatter({ weekday: "narrow" }) +const weekdays: ReadonlyArray<string> = Array.from({ length: 7 }, (_, index) => + narrowWeekday.format(new Date(Date.UTC(2026, 2, 1 + index))), +) /** useCalendarGrid's narrow weekday names, Sunday first. */ -export const weekdayNames = (): ReadonlyArray<string> => { - const narrow = formatter({ weekday: "narrow" }) - return Array.from({ length: 7 }, (_, index) => narrow.format(new Date(Date.UTC(2026, 2, 1 + index)))) -} - -/** React Aria's `formatRange`: Intl's range parts joined by "to" instead of the shared literal. */ -export const formatRange = (start: CalendarDate, end: CalendarDate): string => { - const rangeParts = fullDate.formatRangeToParts(toDate(start), toDate(end)) - let separator = -1 - for (const [index, part] of rangeParts.entries()) { - if (part.source === "shared" && part.type === "literal") separator = index - else if (part.source === "endRange") break - } - const startText = rangeParts - .slice(0, Math.max(separator, 0)) - .map((part) => part.value) - .join("") - const endText = rangeParts - .slice(separator + 1) - .map((part) => part.value) - .join("") - return `${startText} to ${endText}` -} +export const weekdayNames = (): ReadonlyArray<string> => weekdays diff --git a/apps/web-foldkit/src/ui/calendar-view.ts b/apps/web-foldkit/src/ui/calendar-view.ts index ea9452fa5..a26f7d6d5 100644 --- a/apps/web-foldkit/src/ui/calendar-view.ts +++ b/apps/web-foldkit/src/ui/calendar-view.ts @@ -8,29 +8,22 @@ import { calendarMonthSelectTriggerClassName, calendarNavButtonClassName, calendarYearSelectTriggerClassName, - rangeCalendarCellClassName, - rangeCalendarCellInnerClassName, - rangeCalendarGridClassName, - rangeCalendarGridsClassName, } from "~/components/ui/calendar.styles" import * as Interaction from "./aria/interaction" import { button } from "./button" import { cellTarget, - FocusCellOnPress, gridId, - highlightedRange, isCellDisabled, isOutsideMonth, isPreviousDisabled, Message, type Model, - selectedDateDescription, } from "./calendar" import * as D from "./calendar-date" import { view as selectView } from "./select-view" -/** Calendar and RangeCalendar markup, React Aria's DOM included. */ +/** Calendar markup: an application region, the month header and a day grid. */ const visuallyHidden = "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;" @@ -127,11 +120,9 @@ const header = (h: HtmlBuilder<Message>, model: Model, monthYear: string): Html ) } -/** useCalendarCell's label: range description, today, selected, then the minimum date. */ -const cellLabel = (model: Model, date: D.CalendarDate, isSelected: boolean, isEdge: boolean) => { - const isRangeEdge = model.mode === "Range" && isSelected && isEdge && Option.isNone(model.anchor) - const description = isRangeEdge ? selectedDateDescription(model) : "" - const dated = `${description === "" ? "" : `${description}, `}${D.formatFull(date)}` +/** A day's accessible name: today, the full date, selected, then the minimum date. */ +const cellLabel = (model: Model, date: D.CalendarDate, isSelected: boolean) => { + const dated = D.formatFull(date) const withToday = date === model.today ? `Today, ${dated}` : dated const label = isSelected ? `${withToday} selected` : withToday return Option.contains(model.minValue, date) ? `${label}, First available date` : label @@ -140,17 +131,7 @@ const cellLabel = (model: Model, date: D.CalendarDate, isSelected: boolean, isEd const cell = (h: HtmlBuilder<Message>, model: Model, date: D.CalendarDate): Html => { const isDisabled = isCellDisabled(model, date) const isOutside = isOutsideMonth(model, date) - const highlighted = model.mode === "Range" ? highlightedRange(model) : Option.none() - const isSelected = - !isOutside && - (model.mode === "Single" - ? Option.contains(model.value, date) - : Option.exists( - highlighted, - ({ start, end }) => D.compare(start, date) <= 0 && D.compare(date, end) <= 0, - )) - const isStart = isSelected && Option.exists(highlighted, ({ start }) => start === date) - const isEnd = isSelected && Option.exists(highlighted, ({ end }) => end === date) + const isSelected = !isOutside && Option.contains(model.value, date) const isToday = date === model.today const target = cellTarget(date) const state = isDisabled ? Interaction.idleState : Interaction.stateOf(model.interaction, target) @@ -159,17 +140,14 @@ const cell = (h: HtmlBuilder<Message>, model: Model, date: D.CalendarDate): Html toParentMessage: (message) => Message.GotInteractionMessage({ message }), } const flag = (name: string, isOn: boolean) => (isOn ? [h.Attribute(name, "true")] : []) - const className = - model.mode === "Single" - ? calendarCellClassName({ isSelected, isDisabled, isToday }, undefined) - : rangeCalendarCellClassName(isToday) + const className = calendarCellClassName({ isSelected, isDisabled, isToday }, undefined) return h.td( [...flag("aria-disabled", isDisabled), ...flag("aria-selected", isSelected), h.Role("gridcell")], [ h.div( [ ...flag("aria-disabled", isDisabled), - h.AriaLabel(cellLabel(model, date, isSelected, isStart || isEnd)), + h.AriaLabel(cellLabel(model, date, isSelected)), h.Class(className), ...flag("data-disabled", isDisabled), ...flag("data-outside-month", isOutside), @@ -177,8 +155,6 @@ const cell = (h: HtmlBuilder<Message>, model: Model, date: D.CalendarDate): Html h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ...flag("data-selected", isSelected), - ...flag("data-selection-end", isEnd), - ...flag("data-selection-start", isStart), ...flag("data-today", isToday), ...Interaction.stateAttributes(h, state), h.Role("button"), @@ -190,22 +166,7 @@ const cell = (h: HtmlBuilder<Message>, model: Model, date: D.CalendarDate): Html h.OnClick(Message.ClickedCell({ date })), ]), ], - [ - model.mode === "Single" - ? D.formatDay(date) - : h.span( - [ - h.Class( - rangeCalendarCellInnerClassName({ - isSelected, - isSelectionEdge: isStart || isEnd, - isDisabled, - }), - ), - ], - [D.formatDay(date)], - ), - ], + [D.formatDay(date)], ), ], ) @@ -215,15 +176,13 @@ const grid = (h: HtmlBuilder<Message>, model: Model, label: string): Html => h.table( [ h.AriaLabel(label), - ...(model.mode === "Range" ? [h.Attribute("aria-multiselectable", "true")] : []), h.Attribute("cellpadding", "0"), - h.Class(model.mode === "Single" ? "react-aria-CalendarGrid" : rangeCalendarGridClassName), + h.Class("react-aria-CalendarGrid"), h.Id(gridId(model.id)), h.Role("grid"), h.OnKeyDownPreventDefault((key) => key === "Tab" ? Option.none() : Option.some(Message.PressedGridKey({ key })), ), - ...(model.mode === "Range" ? [h.OnMount(FocusCellOnPress())] : []), ], [ h.thead( @@ -238,7 +197,7 @@ const grid = (h: HtmlBuilder<Message>, model: Model, label: string): Html => ], ), h.tbody( - [h.Class(model.mode === "Single" ? "react-aria-CalendarGridBody" : "snap-start")], + [h.Class("react-aria-CalendarGridBody")], Array.map(D.monthWeeks(model.focusedDate), (week) => h.tr( [], @@ -255,7 +214,7 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view return h.div( [ h.AriaLabel(label), - h.Class(model.mode === "Single" ? "react-aria-Calendar" : "react-aria-RangeCalendar"), + h.Class("react-aria-Calendar"), h.Attribute("data-rac", ""), h.Attribute("data-slot", "calendar"), h.Id(model.id), @@ -264,9 +223,7 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view [ h.div([h.Attribute("style", visuallyHidden)], [h.h2([], [label])]), header(h, model, monthYear), - model.mode === "Single" - ? grid(h, model, label) - : h.div([h.Class(rangeCalendarGridsClassName)], [grid(h, model, label)]), + grid(h, model, label), h.div( [h.Attribute("style", visuallyHidden)], [h.button([h.AriaLabel("Next"), h.Tabindex(-1), h.OnClick(Message.ClickedNext())])], diff --git a/apps/web-foldkit/src/ui/calendar.scene.test.ts b/apps/web-foldkit/src/ui/calendar.scene.test.ts index 4ad8346c2..838d8f018 100644 --- a/apps/web-foldkit/src/ui/calendar.scene.test.ts +++ b/apps/web-foldkit/src/ui/calendar.scene.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { AnnounceCalendar, FocusCalendarCell, FocusCellOnPress, init, Message, OutMessage, update } from "./calendar" +import { AnnounceCalendar, FocusCalendarCell, init, Message, OutMessage, update } from "./calendar" import { view } from "./calendar-view" import * as Select from "./select" -/** Calendar and RangeCalendar through the view: grid roles, selection state, paging and clicks. */ +/** Calendar through the view: grid roles, selection state, paging and clicks. */ // Fixed dates: March 2026, today the 5th, nothing reads the clock. const today = "2026-03-05" @@ -116,43 +116,3 @@ describe("calendar scene", () => { ) }) }) - -const rangeConfig = { update, view: sceneView({ ariaLabel: "Trip dates" }) } -const trip = init({ id: "trip", mode: "Range", today, range: { start: "2026-03-09", end: "2026-03-13" } }) -const selectedCells = Scene.all.role("gridcell", { selected: true }) -const rangeMounted = [...mounted, Scene.Mount.resolve(FocusCellOnPress, Message.CompletedFocusCellOnPress())] - -describe("range calendar scene", () => { - test("renders a multiselectable grid with the committed range selected", () => { - Scene.scene( - rangeConfig, - Scene.given(trip), - ...rangeMounted, - Scene.expect(Scene.role("grid", { name: "Trip dates, March 2026" })).toHaveAttr("aria-multiselectable", "true"), - Scene.expectAll(selectedCells).toHaveCount(5), - Scene.expect( - day("Selected Range: Monday, March 9 to Friday, March 13, 2026, Monday, March 9, 2026 selected"), - ).toHaveAttr("data-selection-start", "true"), - Scene.expect(day("Wednesday, March 11, 2026 selected")).not.toHaveAttr("data-selection-end"), - ) - }) - - test("clicking two days previews on hover, then commits the range and tells the parent", () => { - Scene.scene( - rangeConfig, - Scene.given(trip), - ...rangeMounted, - Scene.click(day("Monday, March 16, 2026")), - Scene.expectNoOutMessage(), - Scene.expectAll(selectedCells).toHaveCount(1), - Scene.hover(day("Thursday, March 19, 2026")), - Scene.expectAll(selectedCells).toHaveCount(4), - Scene.click(day("Thursday, March 19, 2026 selected")), - Scene.expectOutMessage(OutMessage.ChangedRange({ start: "2026-03-16", end: "2026-03-19" })), - resolveAnnounce, - Scene.expect( - day("Selected Range: Monday, March 16 to Thursday, March 19, 2026, Thursday, March 19, 2026 selected"), - ).toHaveAttr("data-selection-end", "true"), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/calendar.story.test.ts b/apps/web-foldkit/src/ui/calendar.story.test.ts index 84104416f..ed4fb329b 100644 --- a/apps/web-foldkit/src/ui/calendar.story.test.ts +++ b/apps/web-foldkit/src/ui/calendar.story.test.ts @@ -1,11 +1,9 @@ import { Option } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import * as Interaction from "./aria/interaction" import { AnnounceCalendar, FocusCalendarCell, - highlightedRange, init, Message, OutMessage, @@ -13,13 +11,12 @@ import { } from "./calendar" import * as Select from "./select" -/** Calendar and RangeCalendar update: grid keys, paging, minimum date, selection and announcements. */ +/** Calendar update: grid keys, paging, minimum date, selection and announcements. */ // March 2026 starts on a Sunday; the 18th is a Wednesday. Every date is fixed, nothing reads the clock. const today = "2026-03-05" const event = init({ id: "event", today, value: "2026-03-18" }) const deadline = init({ id: "deadline", today, minValue: "2026-03-10" }) -const trip = init({ id: "trip", mode: "Range", today, range: { start: "2026-03-09", end: "2026-03-13" } }) const key = (pressed: string) => message(Message.PressedGridKey({ key: pressed })) const focusCell = (label: string, grid = "event-grid") => FocusCalendarCell({ gridId: grid, label }) @@ -241,105 +238,3 @@ describe("calendar story: single selection", () => { ) }) }) - -describe("range calendar story", () => { - test("the first click starts a range without telling the parent or announcing", () => { - story( - update, - given(trip), - message(Message.ClickedCell({ date: "2026-03-16" })), - expectNoOutMessage(), - Command.expectNone(), - model((next) => { - expect(next.anchor).toEqual(Option.some("2026-03-16")) - expect(highlightedRange(next)).toEqual( - Option.some({ start: "2026-03-16", end: "2026-03-16" }), - ) - }), - ) - }) - - test("hovering a day previews the range from the anchor, in date order", () => { - story( - update, - given(trip), - message(Message.ClickedCell({ date: "2026-03-16" })), - message( - Message.GotInteractionMessage({ - message: Interaction.Message.EnteredTarget({ target: "cell-2026-03-11" }), - }), - ), - model((next) => - expect(highlightedRange(next)).toEqual( - Option.some({ start: "2026-03-11", end: "2026-03-16" }), - ), - ), - message( - Message.GotInteractionMessage({ - message: Interaction.Message.LeftTarget({ target: "cell-2026-03-11" }), - }), - ), - model((next) => - expect(highlightedRange(next)).toEqual( - Option.some({ start: "2026-03-16", end: "2026-03-16" }), - ), - ), - ) - }) - - test("the second click completes the range, emits it and announces it", () => { - story( - update, - given(trip), - message(Message.ClickedCell({ date: "2026-03-20" })), - message(Message.ClickedCell({ date: "2026-03-17" })), - expectOutMessage(OutMessage.ChangedRange({ start: "2026-03-17", end: "2026-03-20" })), - Command.expectExact( - AnnounceCalendar({ - message: "Selected Range: Tuesday, March 17 to Friday, March 20, 2026", - timeout: 4000, - assertiveness: "polite", - }), - ), - resolveAnnounce, - model((next) => { - expect(next.anchor).toEqual(Option.none()) - expect(next.range).toEqual(Option.some({ start: "2026-03-17", end: "2026-03-20" })) - }), - ) - }) - - test("a range of one day is announced as a single date", () => { - story( - update, - given(trip), - key("Enter"), - key("Enter"), - expectOutMessage(OutMessage.ChangedRange({ start: "2026-03-09", end: "2026-03-09" })), - Command.expectExact( - AnnounceCalendar({ - message: "Selected Date: Monday, March 9, 2026", - timeout: 4000, - assertiveness: "polite", - }), - ), - resolveAnnounce, - ) - }) - - test("arrowing after the first click extends the preview to the focused day", () => { - story( - update, - given(trip), - key("Enter"), - key("ArrowDown"), - Command.expectExact(focusCell("Monday, March 16, 2026", "trip-grid")), - resolveFocus, - model((next) => - expect(highlightedRange(next)).toEqual( - Option.some({ start: "2026-03-09", end: "2026-03-16" }), - ), - ), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/calendar.ts b/apps/web-foldkit/src/ui/calendar.ts index b52ee0370..bde1e8927 100644 --- a/apps/web-foldkit/src/ui/calendar.ts +++ b/apps/web-foldkit/src/ui/calendar.ts @@ -1,5 +1,5 @@ import { Array, Effect, Option, Schema } from "effect" -import { Command, Mount, Subscription, type Update } from "foldkit" +import { Command, Subscription, type Update } from "foldkit" import * as Dom from "foldkit/dom" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" @@ -9,25 +9,18 @@ import * as D from "./calendar-date" import * as Select from "./select" /** - * Port of `components/ui/calendar.tsx` and `range-calendar.tsx` (React Aria Calendar and - * RangeCalendar, one month): selection, keyboard navigation, the month and year Selects and the - * page buttons. Hover, press and focus-visible come from an embedded interaction Submodel. + * A single-date calendar (one month): selection, keyboard navigation, the month and year Selects + * and the page buttons. Hover, press and focus-visible come from an embedded interaction Submodel. * The views live in `calendar-view.ts`. */ // MODEL -const Range = Schema.Struct({ start: Schema.String, end: Schema.String }) - export const Model = Schema.Struct({ id: Schema.String, - mode: Schema.Literals(["Single", "Range"]), today: Schema.String, focusedDate: Schema.String, value: Schema.Option(Schema.String), - range: Schema.Option(Range), - /** The first click of a range selection, until the second one completes it. */ - anchor: Schema.Option(Schema.String), minValue: Schema.Option(Schema.String), month: Select.Model, year: Select.Model, @@ -68,21 +61,16 @@ const clampToMin = (minValue: Option.Option<D.CalendarDate>, date: D.CalendarDat export const init = (config: { readonly id: string readonly today: D.CalendarDate - readonly mode?: "Single" | "Range" readonly value?: D.CalendarDate - readonly range?: { readonly start: D.CalendarDate; readonly end: D.CalendarDate } readonly minValue?: D.CalendarDate }): Model => { const minValue = Option.fromNullishOr(config.minValue) - const focusedDate = clampToMin(minValue, config.value ?? config.range?.start ?? config.today) + const focusedDate = clampToMin(minValue, config.value ?? config.today) return { id: config.id, - mode: config.mode ?? "Single", today: config.today, focusedDate, value: Option.fromNullishOr(config.value), - range: Option.fromNullishOr(config.range), - anchor: Option.none(), minValue, month: monthSelect(config.id, focusedDate), year: yearSelect(config.id, focusedDate), @@ -100,45 +88,17 @@ export const isOutsideMonth = (model: Model, date: D.CalendarDate) => !D.isSameM export const isCellDisabled = (model: Model, date: D.CalendarDate) => isOutsideMonth(model, date) || isBeforeMin(model, date) -/** The highlighted range: the committed one, or the anchor up to the hovered or focused day. */ -export const highlightedRange = (model: Model): Option.Option<{ start: string; end: string }> => - Option.match(model.anchor, { - onNone: () => model.range, - onSome: (anchor) => { - const other = Option.getOrElse(hoveredDate(model), () => model.focusedDate) - return Option.some( - D.compare(anchor, other) <= 0 ? { start: anchor, end: other } : { start: other, end: anchor }, - ) - }, - }) - export const cellTarget = (date: D.CalendarDate) => `cell-${date}` -const hoveredDate = (model: Model): Option.Option<string> => - Option.map( - Array.findFirst(model.interaction.hovered, (target) => target.startsWith("cell-")), - (target) => target.slice("cell-".length), - ) - export const isPreviousDisabled = (model: Model) => isBeforeMin(model, D.addDays(D.startOfMonth(model.focusedDate), -1)) -/** useSelectedDateDescription: the committed date or range, "" while a range is being picked. */ +/** useSelectedDateDescription: the committed date, or "" when none. */ export const selectedDateDescription = (model: Model): string => - model.mode === "Single" - ? Option.match(model.value, { - onNone: () => "", - onSome: (date) => `Selected Date: ${D.formatFull(date)}`, - }) - : Option.isSome(model.anchor) - ? "" - : Option.match(model.range, { - onNone: () => "", - onSome: ({ start, end }) => - start === end - ? `Selected Date: ${D.formatFull(start)}` - : `Selected Range: ${D.formatRange(start, end)}`, - }) + Option.match(model.value, { + onNone: () => "", + onSome: (date) => `Selected Date: ${D.formatFull(date)}`, + }) // IDS @@ -156,13 +116,11 @@ export const Message = defineMessageUnion({ GotInteractionMessage: { message: Interaction.Message }, CompletedFocusCell: {}, CompletedAnnounce: {}, - CompletedFocusCellOnPress: {}, }) export type Message = typeof Message.Type export const OutMessage = defineMessageUnion({ ChangedValue: { date: Schema.String }, - ChangedRange: { start: Schema.String, end: Schema.String }, }) export type OutMessage = typeof OutMessage.Type @@ -205,38 +163,6 @@ export const FocusCalendarCell = Command.define("FocusCalendarCell", { ), }) -// MOUNT - -/** - * A RangeCalendar day is focused by script when pressed (useCalendarCell's onPressStart, or - * preventFocus then onPressUp), never by the mouse, so Chrome gives it `:focus-visible`. - */ -export const FocusCellOnPress = Mount.define("FocusCalendarCellOnPress", { - messages: [Message.CompletedFocusCellOnPress], - execute: ({ element }) => - Effect.acquireRelease( - Effect.sync(() => { - const onPointerDown = (event: Event) => { - if ( - !(event instanceof PointerEvent) || - event.button !== 0 || - event.pointerType === "touch" - ) - return - const cell = - event.target instanceof Element - ? event.target.closest("[role=button][tabindex]") - : null - if (cell instanceof HTMLElement && element.contains(cell)) - cell.focus({ preventScroll: true }) - } - element.addEventListener("pointerdown", onPointerDown) - return () => element.removeEventListener("pointerdown", onPointerDown) - }), - (release) => Effect.sync(release), - ).pipe(Effect.as(Message.CompletedFocusCellOnPress())), -}) - // UPDATE type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> @@ -281,27 +207,10 @@ const focusDate = (model: Model, date: D.CalendarDate): UpdateReturn => { const selectDate = (model: Model, date: D.CalendarDate): UpdateReturn => { if (isBeforeMin(model, date)) return { model } - if (model.mode === "Single") - return { - model: modifyFields(withFocusedDate(model, date), { value: () => Option.some(date) }), - outMessage: OutMessage.ChangedValue({ date }), - } - return Option.match(model.anchor, { - onNone: () => ({ - model: modifyFields(withFocusedDate(model, date), { anchor: () => Option.some(date) }), - }), - onSome: (anchor) => { - const range = - D.compare(anchor, date) <= 0 ? { start: anchor, end: date } : { start: date, end: anchor } - return { - model: modifyFields(withFocusedDate(model, date), { - anchor: () => Option.none(), - range: () => Option.some(range), - }), - outMessage: OutMessage.ChangedRange(range), - } - }, - }) + return { + model: modifyFields(withFocusedDate(model, date), { value: () => Option.some(date) }), + outMessage: OutMessage.ChangedValue({ date }), + } } const keyOffsets: Readonly<Record<string, (date: D.CalendarDate) => D.CalendarDate>> = { @@ -395,7 +304,6 @@ const updateCalendar = (model: Model, message: Message): UpdateReturn => }), CompletedFocusCell: () => ({ model }), CompletedAnnounce: () => ({ model }), - CompletedFocusCellOnPress: () => ({ model }), }) // SUBSCRIPTION diff --git a/apps/web-foldkit/src/ui/field-validation-state.ts b/apps/web-foldkit/src/ui/field-validation-state.ts deleted file mode 100644 index 808438829..000000000 --- a/apps/web-foldkit/src/ui/field-validation-state.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { - validationStateIconStyles, - validationStateStyles, -} from "~/components/ui/field-validation-state.styles" -import { IconCheck, IconLoader } from "../icons" - -/** Port of `components/ui/field-validation-state.tsx`. Renders nothing while idle. */ -export type ValidationState = "idle" | "validating" | "valid" | "invalid" - -const invalidIcon = <Message>(h: HtmlBuilder<Message>): Html => - h.svg( - [ - h.Class(validationStateIconStyles), - h.Attribute("viewBox", "0 0 12 12"), - h.Attribute("fill", "none"), - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Attribute("aria-hidden", "true"), - ], - [ - h.path([ - h.Attribute("d", "M2.25 9.75L9.75 2.25M9.75 9.75L2.25 2.25"), - h.Attribute("stroke", "currentColor"), - h.Attribute("stroke-width", "1.5"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - ], - ) - -export const fieldValidationState = <Message>( - h: HtmlBuilder<Message>, - options: { readonly state: ValidationState; readonly className?: string }, -): Html => - options.state === "idle" - ? null - : h.span( - [ - h.Class(twMerge(validationStateStyles({ state: options.state }), options.className)), - h.AriaHidden(true), - ], - [ - options.state === "validating" - ? IconLoader(h, { className: validationStateIconStyles, title: "Validating..." }) - : options.state === "valid" - ? IconCheck(h, { className: validationStateIconStyles, title: "Valid" }) - : invalidIcon(h), - ], - ) - -/** Legacy `getValidationState`: TanStack Form field meta to a state. */ -export const getValidationState = (meta: { - readonly isValidating?: boolean - readonly isTouched?: boolean - readonly errors?: ReadonlyArray<unknown> -}): ValidationState => { - if (meta.isValidating) return "validating" - if (meta.isTouched) return meta.errors?.length ? "invalid" : "valid" - return "idle" -} diff --git a/apps/web-foldkit/src/ui/field.ts b/apps/web-foldkit/src/ui/field.ts index 426b08d00..4f2222012 100644 --- a/apps/web-foldkit/src/ui/field.ts +++ b/apps/web-foldkit/src/ui/field.ts @@ -2,7 +2,6 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { descriptionStyles, - fieldErrorsStyles, fieldErrorStyles, fieldsetStyles, labelStyles, @@ -68,30 +67,6 @@ export const fieldError = <Message>( children, ) -/** Every error as a list; renders nothing without errors. */ -export const fieldErrors = <Message>( - h: HtmlBuilder<Message>, - options: { readonly errors: ReadonlyArray<{ readonly message?: string }>; readonly className?: string }, -): Html | null => - options.errors.length === 0 - ? null - : h.ul( - [ - h.Class(twMerge(fieldErrorStyles(), fieldErrorsStyles.list, options.className)), - h.Role("alert"), - h.AriaLive("polite"), - ], - options.errors.map((error) => - h.li( - [h.Class(fieldErrorsStyles.item)], - [ - h.span([h.Class(fieldErrorsStyles.bullet), h.AriaHidden(true)]), - h.span([], [error.message ?? ""]), - ], - ), - ), - ) - export const fieldset = <Message>( h: HtmlBuilder<Message>, options: PartOptions<Message>, diff --git a/apps/web-foldkit/src/ui/form-error-summary.ts b/apps/web-foldkit/src/ui/form-error-summary.ts deleted file mode 100644 index af8766cd2..000000000 --- a/apps/web-foldkit/src/ui/form-error-summary.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { formErrorSummaryPartStyles, formErrorSummaryStyles } from "~/components/ui/form-error-summary.styles" - -/** Port of `components/ui/form-error-summary.tsx`. Renders nothing without errors. */ -export interface FormError { - readonly field: string - readonly message: string -} - -export const formErrorSummary = <Message>( - h: HtmlBuilder<Message>, - options: { - readonly errors: ReadonlyArray<FormError> - readonly title?: string - readonly className?: string - }, -): Html => - options.errors.length === 0 - ? null - : h.div( - [ - h.Class(twMerge(formErrorSummaryStyles(), options.className)), - h.Role("alert"), - h.AriaLive("polite"), - ], - [ - h.p( - [h.Class(formErrorSummaryPartStyles.title)], - [options.title ?? "Please fix the following errors:"], - ), - h.ul( - [h.Class(formErrorSummaryPartStyles.list)], - options.errors.map((error) => - h.li( - [], - // React writes `{field}:` and ` {message}` as separate text nodes. - [ - h.span([h.Class(formErrorSummaryPartStyles.field)], [error.field, ":"]), - " ", - error.message, - ], - ), - ), - ), - ], - ) diff --git a/apps/web-foldkit/src/ui/input-otp.scene.test.ts b/apps/web-foldkit/src/ui/input-otp.scene.test.ts deleted file mode 100644 index 08c712ecb..000000000 --- a/apps/web-foldkit/src/ui/input-otp.scene.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// @vitest-environment jsdom -import { Function } from "effect" -import * as Scene from "foldkit/scene" -import type { HtmlBuilder } from "foldkit/html" -import { describe, test } from "vitest" -import { - CheckInputOtpPasswordBadge, - init, - inputOtp, - Message, - type Model, - ReadSelection, - SetSelection, - update, -} from "./input-otp" - -/** input-otp through the view: the labelled overlay input, slots, typing, backspace and disabled. */ - -const view = (model: Model, h: HtmlBuilder<Message>) => - inputOtp(h, { model, toParentMessage: Function.identity, ariaLabel: "Verification code" }, (parts) => [ - parts.group([parts.slot(0), parts.slot(1), parts.slot(2)]), - parts.separator(), - parts.group([parts.slot(3), parts.slot(4), parts.slot(5)]), - ]) - -const config = { update, view } -const input = Scene.role("textbox", { name: "Verification code" }) -const slots = Scene.all.selector('[data-slot="input-otp-slot"]') -const slot = (index: number) => Scene.nth(slots, index) -const code = (value = "", isDisabled = false) => - init({ id: "otp-code", maxLength: 6, value, isDisabled }).model -const readSelection = (start: number, end: number) => - Scene.Command.resolve(ReadSelection, Message.ChangedSelection({ selection: { start, end } })) - -describe("input-otp scene", () => { - test("renders a labelled one-time-code input over six empty slots", () => { - Scene.scene( - config, - Scene.given(code()), - Scene.expect(input).toHaveAttr("autocomplete", "one-time-code"), - Scene.expect(input).toHaveAttr("inputmode", "numeric"), - Scene.expect(input).toHaveAttr("maxlength", "6"), - Scene.expect(input).toHaveAttr("data-input-otp-placeholder-shown", "true"), - Scene.expectAll(slots).toHaveCount(6), - Scene.expectAll(Scene.all.selector('[data-slot="input-otp-group"]')).toHaveCount(2), - Scene.expect(slot(0)).toBeEmpty(), - ) - }) - - test("typing digits fills the slots in order", () => { - Scene.scene( - config, - Scene.given(code()), - Scene.type(input, "12"), - readSelection(2, 2), - Scene.expect(input).toHaveValue("12"), - Scene.expect(slot(0)).toHaveText("1"), - Scene.expect(slot(1)).toHaveText("2"), - Scene.expect(slot(2)).toBeEmpty(), - Scene.expect(input).not.toHaveAttr("data-input-otp-placeholder-shown"), - ) - }) - - test("pasting a longer code fills every slot and drops the rest", () => { - Scene.scene( - config, - Scene.given(code()), - Scene.type(input, "98765432"), - readSelection(6, 6), - Scene.expect(input).toHaveValue("987654"), - Scene.expect(slot(5)).toHaveText("4"), - ) - }) - - test("backspace empties the last filled slot", () => { - Scene.scene( - config, - Scene.given(code("123")), - Scene.type(input, "12"), - readSelection(2, 2), - Scene.expect(slot(1)).toHaveText("2"), - Scene.expect(slot(2)).toBeEmpty(), - ) - }) - - test("focusing the input marks the next empty slot active with a caret", () => { - Scene.scene( - config, - Scene.given(code("12")), - Scene.focus(input), - Scene.Command.resolveAll( - [SetSelection, Message.CompletedSetSelection()], - [ReadSelection, Message.ChangedSelection({ selection: { start: 2, end: 2 } })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - ), - Scene.expect(slot(2)).toHaveAttr("data-active", "true"), - Scene.expect(slot(1)).toHaveAttr("data-active", "false"), - Scene.expect(input).toHaveAttr("data-input-otp-mss", "2"), - Scene.expect(input).toHaveAttr("data-input-otp-mse", "2"), - ) - }) - - test("a disabled code shows its digits in a disabled input", () => { - Scene.scene( - config, - Scene.given(code("1234", true)), - Scene.expect(input).toBeDisabled(), - Scene.expect(input).toHaveValue("1234"), - Scene.expect(slot(3)).toHaveText("4"), - Scene.expect(slot(0)).toHaveAttr("data-active", "false"), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/input-otp.story.test.ts b/apps/web-foldkit/src/ui/input-otp.story.test.ts deleted file mode 100644 index d57e45eb4..000000000 --- a/apps/web-foldkit/src/ui/input-otp.story.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Command, expectNoOutMessage, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { - CheckInputOtpPasswordBadge, - init, - Message, - ReadSelection, - SetSelection, - subscriptions, - update, -} from "./input-otp" - -/** input-otp state: one overlay input whose value, focus and selection drive the slots. */ - -const code = init({ id: "otp-code", maxLength: 6 }).model -const typed = (value: string) => message(Message.ChangedValue({ value })) -const readSelection = (start: number, end: number) => - Command.resolve(ReadSelection, Message.ChangedSelection({ selection: { start, end } })) -const badgeChecks = [0, 2000, 5000].map((delayMillis) => - CheckInputOtpPasswordBadge({ id: "otp-code", delayMillis }), -) - -describe("input-otp story", () => { - test("typing a digit stores it and re-reads the caret so the next slot becomes active", () => { - story( - update, - given(code), - typed("1"), - Command.expectExact(ReadSelection({ id: "otp-code" })), - readSelection(1, 1), - model((next) => { - expect(next.value).toBe("1") - expect(next.selection).toEqual({ start: 1, end: 1 }) - }), - typed("12"), - readSelection(2, 2), - model((next) => expect(next.value).toBe("12")), - ) - }) - - test("pasting a code longer than the slots keeps only the first maxLength characters", () => { - story( - update, - given(code), - typed("12345678"), - readSelection(6, 6), - model((next) => expect(next.value).toBe("123456")), - ) - }) - - test("backspace clears the last character and moves the caret back", () => { - story( - update, - given(init({ id: "otp-code", maxLength: 6, value: "123" }).model), - typed("12"), - readSelection(2, 2), - model((next) => { - expect(next.value).toBe("12") - expect(next.selection).toEqual({ start: 2, end: 2 }) - }), - ) - }) - - // API gap: the React wrapper forwards input-otp's `pattern`, the port has no pattern option. - test("letters are accepted because the port has no pattern filter", () => { - story( - update, - given(code), - typed("12ab"), - readSelection(4, 4), - model((next) => expect(next.value).toBe("12ab")), - ) - }) - - // API gap: input-otp's `onComplete` has no OutMessage counterpart. - test("filling every slot reports nothing to the parent", () => { - story(update, given(code), typed("123456"), expectNoOutMessage(), readSelection(6, 6)) - }) -}) - -describe("input-otp story: focus", () => { - test("focusing an empty code puts the caret on the first slot and checks for a password manager badge", () => { - story( - update, - given(code), - message(Message.FocusedInput()), - Command.expectExact( - SetSelection({ id: "otp-code", start: 0, end: 0 }), - ReadSelection({ id: "otp-code" }), - ...badgeChecks, - ), - model((next) => { - expect(next.isFocused).toBe(true) - expect(next.selection).toEqual({ start: 0, end: 0 }) - }), - Command.resolveAll( - [SetSelection, Message.CompletedSetSelection()], - [ReadSelection, Message.ChangedSelection({ selection: { start: 0, end: 0 } })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - ), - model((next) => expect(next.hasPasswordBadge).toBe(false)), - ) - }) - - test("focusing a full code selects its last character so typing replaces it", () => { - story( - update, - given(init({ id: "otp-code", maxLength: 4, value: "1234" }).model), - message(Message.FocusedInput()), - Command.expectHas(SetSelection({ id: "otp-code", start: 3, end: 4 })), - Command.resolveAll( - [SetSelection, Message.CompletedSetSelection()], - [ReadSelection, Message.ChangedSelection({ selection: { start: 3, end: 4 } })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - [CheckInputOtpPasswordBadge, Message.CompletedCheckPasswordBadge({ hasBadge: false })], - ), - model((next) => expect(next.selection).toEqual({ start: 3, end: 4 })), - ) - }) - - test("a detected password manager badge stays detected after later checks miss it", () => { - story( - update, - given(code), - message(Message.CompletedCheckPasswordBadge({ hasBadge: true })), - message(Message.CompletedCheckPasswordBadge({ hasBadge: false })), - model((next) => expect(next.hasPasswordBadge).toBe(true)), - ) - }) - - test("blurring clears focus and re-reads the selection", () => { - story( - update, - given({ ...code, isFocused: true }), - message(Message.BlurredInput()), - Command.expectExact(ReadSelection({ id: "otp-code" })), - Command.resolve(ReadSelection, Message.ChangedSelection({ selection: null })), - model((next) => { - expect(next.isFocused).toBe(false) - expect(next.selection).toBeNull() - }), - ) - }) -}) - -describe("input-otp subscription gating", () => { - test("selectionchange is only observed while focused or while a stale selection waits to clear", () => { - const { modelToDependencies } = subscriptions.selection - expect(modelToDependencies(code).isTracking).toBe(false) - expect(modelToDependencies({ ...code, isFocused: true }).isTracking).toBe(true) - expect(modelToDependencies({ ...code, selection: { start: 1, end: 1 } }).isTracking).toBe(true) - }) -}) diff --git a/apps/web-foldkit/src/ui/input-otp.ts b/apps/web-foldkit/src/ui/input-otp.ts deleted file mode 100644 index b24a49644..000000000 --- a/apps/web-foldkit/src/ui/input-otp.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { Duration, Effect, Equivalence, Option, Schema, Stream } from "effect" -import { Command, Dom, Subscription, type Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { twMerge } from "tailwind-merge" -import { fieldStyles } from "~/components/ui/field.styles" -import { inputOtpStyles } from "~/components/ui/input-otp.styles" -import { IconMinus } from "../icons" - -/** - * Port of `components/ui/input-otp.tsx` and the `input-otp` library it wraps: a transparent input - * over rendered slots. The library's selection tracking, focus-time selection, password manager badge - * check and measured `--root-height` are reproduced so slots, caret and attributes match. - */ - -// MODEL - -const Selection = Schema.Struct({ start: Schema.Number, end: Schema.Number }) - -export const Model = Schema.Struct({ - id: Schema.String, - maxLength: Schema.Number, - value: Schema.String, - isDisabled: Schema.Boolean, - isFocused: Schema.Boolean, - selection: Schema.NullOr(Selection), - rootHeight: Schema.NullOr(Schema.Number), - hasBadgeSpace: Schema.Boolean, - hasPasswordBadge: Schema.Boolean, -}) -export type Model = typeof Model.Type - -// MESSAGE - -export const Message = defineMessageUnion({ - ChangedValue: { value: Schema.String }, - FocusedInput: {}, - BlurredInput: {}, - ChangedSelection: { selection: Schema.NullOr(Selection) }, - CompletedMeasureInput: { rootHeight: Schema.Number, hasBadgeSpace: Schema.Boolean }, - CompletedCheckPasswordBadge: { hasBadge: Schema.Boolean }, - CompletedInstallInputOtpStyle: {}, - CompletedSetSelection: {}, -}) -export type Message = typeof Message.Type - -// COMMAND - -const inputOf = (id: string) => Option.fromNullishOr(document.getElementById(id)) -const containerOf = (input: Element) => Option.fromNullishOr(input.closest("[data-input-otp-container]")) - -/** The library's injected stylesheet: transparent selection and autofill on the overlay input. */ -const InstallInputOtpStyle = Command.define("InstallInputOtpStyle", { - messages: [Message.CompletedInstallInputOtpStyle], - execute: Effect.sync(() => { - if (document.getElementById("input-otp-style") === null) { - const style = document.createElement("style") - style.id = "input-otp-style" - document.head.appendChild(style) - const hidden = - "background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;" - for (const rule of [ - "[data-input-otp]::selection { background: transparent !important; color: transparent !important; }", - `[data-input-otp]:autofill { ${hidden} }`, - `[data-input-otp]:-webkit-autofill { ${hidden} }`, - "@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }", - "[data-input-otp] + * { pointer-events: all !important; }", - ]) - style.sheet?.insertRule(rule) - } - return Message.CompletedInstallInputOtpStyle() - }), -}) - -/** The mount-time measurements: the input's height (`--root-height`) and room for a badge. */ -const MeasureInputOtp = Command.define("MeasureInputOtp", { - args: { id: Schema.String }, - messages: [Message.CompletedMeasureInput], - execute: ({ id }) => - Effect.promise( - () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), - ).pipe( - Effect.map(() => - Option.match(inputOf(id), { - onNone: () => Message.CompletedMeasureInput({ rootHeight: 0, hasBadgeSpace: false }), - onSome: (input) => - Message.CompletedMeasureInput({ - rootHeight: input.clientHeight, - hasBadgeSpace: Option.match(containerOf(input), { - onNone: () => false, - onSome: (container) => - window.innerWidth - container.getBoundingClientRect().right >= 40, - }), - }), - }), - ), - ), -}) - -const passwordManagerSelectors = [ - "[data-lastpass-icon-root]", - "com-1password-button", - "[data-dashlanecreated]", - '[style$="2147483647 !important;"]', -].join(",") - -/** usePasswordManagerBadge: something other than the container at its right edge is a badge. */ -export const CheckInputOtpPasswordBadge = Command.define("CheckInputOtpPasswordBadge", { - args: { id: Schema.String, delayMillis: Schema.Number }, - messages: [Message.CompletedCheckPasswordBadge], - execute: ({ id, delayMillis }) => - Effect.sleep(Duration.millis(delayMillis)).pipe( - Effect.map(() => - Message.CompletedCheckPasswordBadge({ - hasBadge: Option.match(Option.flatMap(inputOf(id), containerOf), { - onNone: () => false, - onSome: (container) => { - const rect = container.getBoundingClientRect() - const right = - rect.left + - (container instanceof HTMLElement ? container.offsetWidth : rect.width) - const middle = - rect.top + - (container instanceof HTMLElement ? container.offsetHeight : rect.height) / 2 - return !( - document.querySelectorAll(passwordManagerSelectors).length === 0 && - document.elementFromPoint(right - 18, middle) === container - ) - }, - }), - }), - ), - ), -}) - -/** On focus the library selects from the last character (or the end). */ -export const SetSelection = Command.define("SetSelection", { - args: { id: Schema.String, start: Schema.Number, end: Schema.Number }, - messages: [Message.CompletedSetSelection], - execute: ({ id, start, end }) => - Effect.sync(() => { - const input = document.getElementById(id) - if (input instanceof HTMLInputElement) input.setSelectionRange(start, end) - return Message.CompletedSetSelection() - }), -}) - -/** After a value or focus change the library re-reads the input's selection (its 0/10/50ms timers). */ -export const ReadSelection = Command.define("ReadSelection", { - args: { id: Schema.String }, - messages: [Message.ChangedSelection], - execute: ({ id }) => - Effect.sleep(Duration.millis(50)).pipe( - Effect.map(() => { - const input = document.getElementById(id) - return Message.ChangedSelection({ - selection: - input instanceof HTMLInputElement && - input.selectionStart !== null && - input.selectionEnd !== null - ? { start: input.selectionStart, end: input.selectionEnd } - : null, - }) - }), - ), -}) - -// INIT - -export const init = (options: { - readonly id: string - readonly maxLength: number - readonly value?: string - readonly isDisabled?: boolean -}): Update.Return<Model, Message> => ({ - model: { - id: options.id, - maxLength: options.maxLength, - value: options.value ?? "", - isDisabled: options.isDisabled ?? false, - isFocused: false, - selection: null, - rootHeight: null, - hasBadgeSpace: false, - hasPasswordBadge: false, - }, - commands: [ - InstallInputOtpStyle(), - MeasureInputOtp({ id: options.id }), - ReadSelection({ id: options.id }), - ], -}) - -// UPDATE - -export const update = (model: Model, message: Message): Update.Return<Model, Message> => - Message.match<Update.Return<Model, Message>>(message, { - ChangedValue: ({ value }) => ({ - model: modifyFields(model, { value: () => value.slice(0, model.maxLength) }), - commands: [ReadSelection({ id: model.id })], - }), - FocusedInput: () => { - const start = Math.min(model.value.length, model.maxLength - 1) - const end = model.value.length - return { - model: modifyFields(model, { isFocused: () => true, selection: () => ({ start, end }) }), - commands: [ - SetSelection({ id: model.id, start, end }), - ReadSelection({ id: model.id }), - ...[0, 2000, 5000].map((delayMillis) => - CheckInputOtpPasswordBadge({ id: model.id, delayMillis }), - ), - ], - } - }, - BlurredInput: () => ({ - model: modifyFields(model, { isFocused: () => false }), - commands: [ReadSelection({ id: model.id })], - }), - ChangedSelection: ({ selection }) => ({ model: modifyFields(model, { selection: () => selection }) }), - CompletedMeasureInput: ({ rootHeight, hasBadgeSpace }) => ({ - model: modifyFields(model, { rootHeight: () => rootHeight, hasBadgeSpace: () => hasBadgeSpace }), - }), - CompletedCheckPasswordBadge: ({ hasBadge }) => ({ - model: modifyFields(model, { hasPasswordBadge: (current) => current || hasBadge }), - }), - CompletedInstallInputOtpStyle: () => ({ model }), - CompletedSetSelection: () => ({ model }), - }) - -// SUBSCRIPTION - -/** - * The library's selectionchange handler: a collapsed caret inside the value becomes a one-character - * selection (so typing replaces it), and any other focused element clears the selection. - */ -const selectionAfterChange = ( - id: string, - maxLength: number, - previous: { start: number; end: number } | null, -) => { - const input = document.getElementById(id) - if (!(input instanceof HTMLInputElement) || document.activeElement !== input) return null - const start = input.selectionStart - const end = input.selectionEnd - const value = input.value - let nextStart = -1 - let nextEnd = -1 - let direction: "forward" | "backward" | undefined - if (value.length !== 0 && start !== null && end !== null) { - const isCollapsed = start === end - const isAtTypingEnd = start === value.length && value.length < maxLength - if (isCollapsed && !isAtTypingEnd) { - if (start === 0) { - nextStart = 0 - nextEnd = 1 - direction = "forward" - } else if (start === maxLength) { - nextStart = start - 1 - nextEnd = start - direction = "backward" - } else if (maxLength > 1 && value.length > 1) { - let offset = 0 - if (previous !== null) { - direction = start < previous.end ? "backward" : "forward" - const wasCollapsed = previous.start === previous.end && previous.start < maxLength - if (direction === "backward" && !wasCollapsed) offset = -1 - } - nextStart = offset + start - nextEnd = offset + start + 1 - } - } - if (nextStart !== -1 && nextEnd !== -1 && nextStart !== nextEnd) - input.setSelectionRange(nextStart, nextEnd, direction) - } - return { - start: nextStart !== -1 ? nextStart : (start ?? 0), - end: nextEnd !== -1 ? nextEnd : (end ?? 0), - } -} - -/** - * Listens while focused, or until a selectionchange clears a selection left after blur, so the - * rendered `data-input-otp-mss/mse` still match the library's always-on listener. - */ -export const subscriptions = Subscription.make<Model, Message>()((entry) => ({ - selection: entry( - { - id: Schema.String, - maxLength: Schema.Number, - selection: Schema.NullOr(Selection), - isTracking: Schema.Boolean, - }, - { - modelToDependencies: (model) => ({ - id: model.id, - maxLength: model.maxLength, - selection: model.selection, - isTracking: model.isFocused || model.selection !== null, - }), - keepAliveEquivalence: Equivalence.make( - (a, b) => a.id === b.id && a.maxLength === b.maxLength && a.isTracking === b.isTracking, - ), - dependenciesToStream: ({ isTracking }, readDependencies) => - !isTracking - ? Stream.empty - : Dom.streamFromEvent({ - target: document, - type: "selectionchange", - mapEvent: () => { - const { id, maxLength, selection } = readDependencies() - return Message.ChangedSelection({ - selection: selectionAfterChange(id, maxLength, selection), - }) - }, - options: { capture: true }, - }), - }, - ), -})) - -// VIEW - -export interface InputOtpParts { - readonly group: (children: Array<Html>, options?: { readonly className?: string }) => Html - readonly slot: (index: number, options?: { readonly className?: string }) => Html - readonly separator: () => Html -} - -const inputStyle = (pushesBadge: boolean) => - [ - "position: absolute;", - "inset: 0px;", - pushesBadge ? "width: calc(100% + 40px);" : "width: 100%;", - "height: 100%;", - "display: flex;", - "text-align: left;", - "opacity: 1;", - "color: transparent;", - "pointer-events: all;", - "background: transparent;", - "caret-color: transparent;", - "border: 0px solid transparent;", - "outline: transparent solid 0px;", - "box-shadow: none;", - "line-height: 1;", - "letter-spacing: -0.5em;", - "font-size: var(--root-height);", - "font-family: monospace;", - "font-variant-numeric: tabular-nums;", - ...(pushesBadge ? ["clip-path: inset(0px 40px 0px 0px);"] : []), - ].join(" ") - -export const inputOtp = <ParentMessage>( - h: HtmlBuilder<ParentMessage>, - options: { - readonly model: Model - readonly toParentMessage: (message: Message) => ParentMessage - readonly ariaLabel?: string - readonly className?: string - readonly containerClassName?: string - }, - render: (parts: InputOtpParts) => Array<Html>, -): Html => { - const model = options.model - const send = options.toParentMessage - const selection = model.selection - const parts: InputOtpParts = { - group: (children, part = {}) => - h.span( - [ - h.DataAttribute("slot", "input-otp-group"), - h.Class(twMerge(inputOtpStyles.group, part.className)), - ], - children, - ), - slot: (index, part = {}) => { - const char = model.value[index] ?? null - const isActive = - model.isFocused && - selection !== null && - ((selection.start === selection.end && index === selection.start) || - (index >= selection.start && index < selection.end)) - return h.div( - [ - h.DataAttribute("slot", "input-otp-slot"), - h.DataAttribute("active", String(isActive)), - h.Class(twMerge(inputOtpStyles.slot, part.className)), - ], - [ - ...(char === null ? [] : [char]), - ...(isActive && char === null - ? [ - h.div( - [h.Class(inputOtpStyles.caret)], - [h.div([h.Class(inputOtpStyles.caretBar)])], - ), - ] - : []), - ], - ) - }, - separator: () => - h.div( - [h.DataAttribute("slot", "input-otp-separator")], - [IconMinus(h, { className: inputOtpStyles.separatorIcon })], - ), - } - const containerStyle = `position: relative; cursor: ${model.isDisabled ? "default" : "text"}; user-select: none; pointer-events: none;${model.rootHeight === null ? "" : ` --root-height: ${model.rootHeight}px;`}` - return h.span( - [h.DataAttribute("slot", "control"), h.Class(inputOtpStyles.wrapper)], - [ - h.div( - [ - h.Class(twMerge(fieldStyles(), options.containerClassName)), - h.DataAttribute("input-otp-container", "true"), - h.Attribute("style", containerStyle), - ], - [ - ...render(parts), - h.div( - [h.Attribute("style", "position: absolute; inset: 0px; pointer-events: none;")], - [ - h.input([ - h.Id(model.id), - h.Class(twMerge(inputOtpStyles.input, options.className)), - h.Attribute("autocomplete", "one-time-code"), - h.DataAttribute("input-otp", "true"), - h.DataAttribute("slot", "input-otp"), - ...(model.value.length === 0 - ? [h.DataAttribute("input-otp-placeholder-shown", "true")] - : []), - ...(selection === null - ? [] - : [ - h.DataAttribute("input-otp-mss", String(selection.start)), - h.DataAttribute("input-otp-mse", String(selection.end)), - ]), - ...(options.ariaLabel === undefined ? [] : [h.AriaLabel(options.ariaLabel)]), - h.Attribute("inputmode", "numeric"), - h.Attribute("maxlength", String(model.maxLength)), - // willPushPWMBadge: a detected badge keeps the overlay widened after blur too. - h.Attribute( - "style", - inputStyle(model.hasPasswordBadge && model.hasBadgeSpace), - ), - h.Value(model.value), - ...(model.isDisabled ? [h.Disabled(true)] : []), - h.OnInput((value) => send(Message.ChangedValue({ value }))), - h.OnFocus(send(Message.FocusedInput())), - h.OnBlur(send(Message.BlurredInput())), - ]), - ], - ), - ], - ), - ], - ) -} diff --git a/apps/web-foldkit/src/ui/list-box-view.ts b/apps/web-foldkit/src/ui/list-box-view.ts deleted file mode 100644 index 61b1fbb68..000000000 --- a/apps/web-foldkit/src/ui/list-box-view.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Array, Option } from "effect" -import { Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { - dropdownCheckIndicatorClassName, - dropdownDescriptionBase, - dropdownItemStyles, - dropdownKeyboardBase, - dropdownLabelBase, - dropdownSectionStyles, - dropdownSeparatorBase, -} from "~/components/ui/dropdown.styles" -import { keyboardStyles } from "~/components/ui/keyboard.styles" -import { - listBoxBase, - listBoxCheckIconClassName, - listBoxItemClassName, - listBoxSectionClassName, -} from "~/components/ui/list-box.styles" -import { IconCheck } from "../icons" -import { focusScopeSentinel } from "./aria/overlay" -import { descriptionId, type Item, labelId, listId, Message, type Model, optionId } from "./list-box" - -/** Port of the ListBox markup (`list-box.tsx`, `dropdown.tsx`), React Aria's DOM included. */ - -// CONTENT HELPERS - -/** `ListBoxLabel` / `DropdownLabel`: the option's accessible name. */ -export const listBoxLabel = <M>( - h: HtmlBuilder<M>, - id: string, - key: string, - text: string, - className?: string, -) => - h.span( - [ - h.Class(twMerge(dropdownLabelBase, className)), - h.Id(labelId(id, key)), - h.Attribute("slot", "label"), - ], - [text], - ) - -/** `ListBoxDescription` / `DropdownDescription`. */ -export const listBoxDescription = <M>(h: HtmlBuilder<M>, id: string, key: string, text: string) => - h.span( - [ - h.Class(twMerge(dropdownDescriptionBase)), - h.Id(descriptionId(id, key)), - h.Attribute("slot", "description"), - ], - [text], - ) - -/** `DropdownKeyboard` over `Keyboard`. */ -export const dropdownKeyboard = <M>(h: HtmlBuilder<M>, text: string) => - h.kbd( - [ - h.Class(twMerge(keyboardStyles, twMerge(dropdownKeyboardBase))), - h.Attribute("data-slot", "keyboard"), - h.Attribute("dir", "ltr"), - ], - [text], - ) - -// VIEW - -export type ViewInputs = Readonly<{ - ariaLabel: string - /** Option children after the check icon; defaults to a label with the item's text. */ - content?: (key: string) => ReadonlyArray<Html> - className?: string -}> - -const flag = (h: HtmlBuilder<Message>, name: string, isOn: boolean) => - isOn ? [h.Attribute(name, "true")] : [] - -const option = (h: HtmlBuilder<Message>, model: Model, viewInputs: ViewInputs, entry: Item): Html => { - const { key } = entry - const isFocused = model.isFocusWithin && Option.contains(model.focusedKey, key) - const isSelected = model.selectedKeys.includes(key) - const allowsSelection = model.selectionMode !== "none" - const isHovered = allowsSelection && !entry.isDisabled && Option.contains(model.hoveredKey, key) - const isPressed = Option.contains(model.pressedKey, key) - const isFocusVisible = isFocused && model.modality !== "Pointer" - const renderProps = { - isSelected, - isFocused, - isHovered, - isDisabled: entry.isDisabled, - isPressed, - isFocusVisible, - } - const className = - entry.flavor === "ListBox" - ? dropdownItemStyles({ ...renderProps, className: listBoxItemClassName(false, undefined) }) - : dropdownItemStyles({ ...renderProps, intent: entry.intent ?? undefined, className: undefined }) - const check = isSelected - ? entry.flavor === "ListBox" - ? [ - IconCheck(h, { - className: listBoxCheckIconClassName, - attributes: { "data-slot": "check-icon" }, - }), - ] - : [ - IconCheck(h, { - className: dropdownCheckIndicatorClassName, - attributes: { "data-slot": "check-indicator" }, - }), - ] - : [] - const content = viewInputs.content?.(key) ?? [listBoxLabel(h, model.id, key, entry.textValue)] - return h.keyed("div")( - key, - [ - ...(entry.hasDescription ? [h.Attribute("aria-describedby", descriptionId(model.id, key))] : []), - ...flag(h, "aria-disabled", entry.isDisabled), - h.Attribute("aria-labelledby", labelId(model.id, key)), - ...(allowsSelection ? [h.Attribute("aria-selected", isSelected ? "true" : "false")] : []), - h.Class(className), - h.Attribute("data-collection", listId(model.id)), - ...flag(h, "data-disabled", entry.isDisabled), - ...flag(h, "data-focus-visible", isFocusVisible), - ...flag(h, "data-focused", isFocused), - ...flag(h, "data-hovered", isHovered), - h.Attribute("data-key", key), - ...flag(h, "data-pressed", isPressed), - h.Attribute("data-rac", ""), - ...(entry.isDisabled ? [] : [h.Attribute("data-react-aria-pressable", "true")]), - ...flag(h, "data-selected", isSelected), - ...(allowsSelection ? [h.Attribute("data-selection-mode", model.selectionMode)] : []), - ...(entry.flavor === "ListBox" ? [h.Attribute("data-slot", "list-box-item")] : []), - h.Id(optionId(model.id, key)), - h.Role("option"), - ...(entry.isDisabled - ? [] - : [ - h.Attribute("tabindex", Option.contains(model.focusedKey, key) ? "0" : "-1"), - h.OnMouseEnter(Message.HoveredItem({ key })), - h.OnPointerLeave(() => Option.some(Message.UnhoveredItem({ key }))), - h.OnPointerDown((_pointerType, button) => - Option.some(Message.PressedItem({ key, button })), - ), - h.OnFocus(Message.FocusedItem({ key })), - ]), - ], - [...check, ...content], - ) -} - -export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const { section, header } = dropdownSectionStyles() - const children = Array.map(model.entries, (entry, index) => { - if (entry._tag === "Item") return option(h, model, viewInputs, entry.item) - if (entry._tag === "Separator") - return h.div([h.Class(twMerge(dropdownSeparatorBase)), h.Role("separator")]) - const headerId = `${listId(model.id)}-section-${index}` - return h.section( - [ - ...(entry.title === null ? [] : [h.Attribute("aria-labelledby", headerId)]), - h.Class( - section({ - className: entry.isListBoxSection ? listBoxSectionClassName(undefined) : undefined, - }), - ), - h.Attribute("data-rac", ""), - h.Role("group"), - ], - [ - ...(entry.title === null - ? [] - : [h.header([h.Class(header()), h.Id(headerId), h.Role("presentation")], [entry.title])]), - ...Array.map(entry.items, (item) => option(h, model, viewInputs, item)), - ], - ) - }) - return h.div( - [ - h.AriaLabel(viewInputs.ariaLabel), - ...(model.selectionMode === "multiple" ? [h.Attribute("aria-multiselectable", "true")] : []), - h.Class(twMerge(twMerge(listBoxBase), viewInputs.className)), - h.Attribute("data-collection", listId(model.id)), - h.Attribute("data-layout", "stack"), - h.Attribute("data-orientation", "vertical"), - h.Attribute("data-rac", ""), - h.Id(listId(model.id)), - h.Role("listbox"), - h.Attribute("tabindex", Option.isSome(model.focusedKey) ? "-1" : "0"), - h.OnFocus(Message.FocusedList({ isFromAfter: false })), - h.OnFocusLeave(Message.LeftList()), - h.OnKeyDownPreventDefault((key, modifiers) => - key === "Tab" - ? Option.none() - : Option.some( - Message.PressedKey({ - key, - isModified: modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey, - }), - ), - ), - h.OnKeyUp((key) => Message.ReleasedKey({ key })), - ], - children, - ) -}) - -/** A standalone ListBox sits in a FocusScope: hidden sentinels around it, which the host renders. */ -export const withFocusScope = <M>(h: HtmlBuilder<M>, listBox: Html): ReadonlyArray<Html> => [ - focusScopeSentinel(h, "start"), - listBox, - focusScopeSentinel(h, "end"), -] diff --git a/apps/web-foldkit/src/ui/list-box.scene.test.ts b/apps/web-foldkit/src/ui/list-box.scene.test.ts deleted file mode 100644 index 3e721227a..000000000 --- a/apps/web-foldkit/src/ui/list-box.scene.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment jsdom -import { Option } from "effect" -import * as Scene from "foldkit/scene" -import { describe, test } from "vitest" -import { entry, FocusListBoxItem, init, item, Message, section, update, WaitForListBoxTypeaheadReset } from "./list-box" -import { view } from "./list-box-view" - -/** ListBox through the view: listbox and option roles, selection state, keyboard and pointer flows. */ - -const sceneView = Scene.withViewInputs(view, { ariaLabel: "Theme" }) -const config = { update, view: sceneView() } -const option = (name: string) => Scene.role("option", { name }) -const listbox = Scene.role("listbox", { name: "Theme" }) - -const themes = init({ - id: "theme", - entries: [ - entry(item("light", "Light")), - entry(item("dark", "Dark", { isDisabled: true })), - entry(item("dim", "Dim")), - ], - selectionMode: "single", - selectedKeys: ["light"], -}) -const focusedOn = (key: string) => ({ ...themes, focusedKey: Option.some(key), isFocusWithin: true }) - -describe("list-box scene", () => { - test("renders a labelled listbox whose options expose selection and disabled state", () => { - Scene.scene( - config, - Scene.given(themes), - Scene.expectAll(Scene.all.role("option")).toHaveCount(3), - Scene.expect(listbox).not.toHaveAttr("aria-multiselectable"), - Scene.expect(option("Light")).toHaveAttr("aria-selected", "true"), - Scene.expect(option("Dim")).toHaveAttr("aria-selected", "false"), - Scene.expect(option("Dark")).toHaveAttr("aria-disabled", "true"), - ) - }) - - test("pressing an option selects it and moves the check mark", () => { - Scene.scene( - config, - Scene.given(themes), - Scene.pointerDown(option("Dim")), - Scene.Command.expectExact(FocusListBoxItem({ elementId: "theme-listbox-option-dim" })), - Scene.Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - Scene.expect(option("Dim")).toHaveAttr("aria-selected", "true"), - Scene.expect(option("Dim")).toHaveAttr("data-focused", "true"), - Scene.expect(option("Dim")).not.toHaveAttr("data-focus-visible"), - Scene.expect(option("Light")).toHaveAttr("aria-selected", "false"), - ) - }) - - test("a disabled option has no press handler", () => { - Scene.scene( - config, - Scene.given(themes), - Scene.expect(option("Light")).toHaveHandler("pointerdown"), - Scene.expect(option("Dark")).not.toHaveHandler("pointerdown"), - Scene.expect(option("Dark")).toHaveAttr("aria-selected", "false"), - ) - }) - - test("ArrowDown skips the disabled option and shows keyboard focus", () => { - Scene.scene( - config, - Scene.given(focusedOn("light")), - Scene.keydown(listbox, "ArrowDown"), - Scene.Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - Scene.expect(option("Dim")).toHaveAttr("data-focus-visible", "true"), - Scene.expect(option("Light")).not.toHaveAttr("data-focused"), - ) - }) - - test("Space selects the focused option from the keyboard", () => { - Scene.scene( - config, - Scene.given(focusedOn("dim")), - Scene.keydown(listbox, " "), - Scene.expect(option("Dim")).toHaveAttr("aria-selected", "true"), - Scene.expect(option("Dim")).toHaveAttr("data-pressed", "true"), - ) - }) - - test("Tab is left to the browser", () => { - Scene.scene(config, Scene.given(focusedOn("dim")), Scene.keydown(listbox, "Tab"), Scene.expectIgnored()) - }) - - test("typing a letter focuses the matching option", () => { - Scene.scene( - config, - Scene.given(focusedOn("light")), - Scene.keydown(listbox, "d"), - Scene.Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - Scene.Command.resolve(WaitForListBoxTypeaheadReset, Message.CompletedWaitForTypeaheadReset({ search: "d" })), - Scene.expect(option("Dim")).toHaveAttr("data-focused", "true"), - ) - }) - - test("multiple selection marks the listbox multiselectable", () => { - Scene.scene( - config, - Scene.given({ ...themes, selectionMode: "multiple" }), - Scene.expect(listbox).toHaveAttr("aria-multiselectable", "true"), - Scene.pointerDown(option("Dim")), - Scene.Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - Scene.expect(option("Light")).toHaveAttr("aria-selected", "true"), - Scene.expect(option("Dim")).toHaveAttr("aria-selected", "true"), - ) - }) - - test("sections render as groups named by their header", () => { - Scene.scene( - config, - Scene.given( - init({ - id: "jump", - entries: [section("Channels", [item("general", "general")]), section(null, [item("alan", "Alan")])], - }), - ), - Scene.expect(Scene.role("group", { name: "Channels" })).toContainText("general"), - Scene.expectAll(Scene.all.role("group")).toHaveCount(2), - Scene.expect(option("general")).not.toHaveAttr("aria-selected"), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/list-box.story.test.ts b/apps/web-foldkit/src/ui/list-box.story.test.ts deleted file mode 100644 index e34ef0a55..000000000 --- a/apps/web-foldkit/src/ui/list-box.story.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Option } from "effect" -import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { - entry, - FocusListBoxItem, - init, - item, - Message, - OutMessage, - update, - WaitForListBoxTypeaheadReset, -} from "./list-box" - -/** React Aria ListBox: roving focus without wrapping, typeahead, and toggle selection. */ - -const themes = init({ - id: "theme", - entries: [ - entry(item("light", "Light")), - entry(item("dark", "Dark", { isDisabled: true })), - entry(item("dim", "Dim")), - entry(item("system", "System")), - ], - selectionMode: "single", - selectedKeys: ["system"], -}) -const focusedOn = (key: string) => ({ ...themes, focusedKey: Option.some(key), isFocusWithin: true }) -const press = (key: string, isModified = false) => message(Message.PressedKey({ key, isModified })) -const focusOf = (key: string) => FocusListBoxItem({ elementId: `theme-listbox-option-${key}` }) - -describe("list-box story", () => { - test("tabbing into the list focuses the selected option", () => { - story( - update, - given(themes), - message(Message.FocusedList({ isFromAfter: false })), - Command.expectExact(focusOf("system")), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => { - expect(next.focusedKey).toEqual(Option.some("system")) - expect(next.isFocusWithin).toBe(true) - }), - ) - }) - - test("ArrowDown skips the disabled option", () => { - story( - update, - given(focusedOn("light")), - press("ArrowDown"), - Command.expectExact(focusOf("dim")), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => { - expect(next.focusedKey).toEqual(Option.some("dim")) - expect(next.modality).toBe("Keyboard") - }), - ) - }) - - test("ArrowUp on the first option stays put instead of wrapping", () => { - story( - update, - given(focusedOn("light")), - press("ArrowUp"), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => expect(next.focusedKey).toEqual(Option.some("light"))), - ) - }) - - test("Home and End jump to the first and last enabled option", () => { - story( - update, - given(focusedOn("dim")), - press("End"), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => expect(next.focusedKey).toEqual(Option.some("system"))), - press("Home"), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => expect(next.focusedKey).toEqual(Option.some("light"))), - ) - }) - - test("Enter selects the focused option and reports the new selection", () => { - story( - update, - given(focusedOn("dim")), - press("Enter"), - Command.expectNone(), - expectOutMessage(OutMessage.ChangedSelection({ keys: ["dim"] })), - model((next) => { - expect(next.selectedKeys).toEqual(["dim"]) - expect(next.pressedKey).toEqual(Option.some("dim")) - }), - message(Message.ReleasedKey({ key: "Enter" })), - model((next) => expect(next.pressedKey).toEqual(Option.none())), - ) - }) - - test("pressing the selected option again deselects it in single mode", () => { - story( - update, - given(themes), - message(Message.PressedItem({ key: "system", button: 0 })), - Command.expectExact(focusOf("system")), - expectOutMessage(OutMessage.ChangedSelection({ keys: [] })), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => expect(next.modality).toBe("Pointer")), - ) - }) - - test("multiple selection adds and removes keys independently", () => { - story( - update, - given(init({ ...themes, selectionMode: "multiple", selectedKeys: ["light"] })), - message(Message.PressedItem({ key: "dim", button: 0 })), - expectOutMessage(OutMessage.ChangedSelection({ keys: ["light", "dim"] })), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - message(Message.PressedItem({ key: "light", button: 0 })), - expectOutMessage(OutMessage.ChangedSelection({ keys: ["dim"] })), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - ) - }) - - test("disabled options and secondary buttons never select", () => { - story( - update, - given(themes), - message(Message.PressedItem({ key: "dark", button: 0 })), - Command.expectNone(), - expectNoOutMessage(), - message(Message.PressedItem({ key: "light", button: 2 })), - Command.expectNone(), - expectNoOutMessage(), - model((next) => expect(next.selectedKeys).toEqual(["system"])), - ) - }) - - test("typeahead focuses the next match and clears the search after a pause", () => { - story( - update, - given(focusedOn("light")), - press("s"), - Command.expectExact(focusOf("system"), WaitForListBoxTypeaheadReset({ search: "s" })), - Command.resolve(FocusListBoxItem, Message.CompletedFocusItem()), - model((next) => expect(next.search).toBe("s")), - Command.resolve( - WaitForListBoxTypeaheadReset, - Message.CompletedWaitForTypeaheadReset({ search: "s" }), - ), - model((next) => expect(next.search).toBe("")), - ) - }) - - test("a stale typeahead timer does not clear a longer search", () => { - story( - update, - given({ ...focusedOn("dim"), search: "di" }), - message(Message.CompletedWaitForTypeaheadReset({ search: "d" })), - model((next) => expect(next.search).toBe("di")), - ) - }) - - test("Space during an active search types into it instead of selecting", () => { - story( - update, - given({ ...focusedOn("dim"), search: "d" }), - press(" "), - Command.expectExact(WaitForListBoxTypeaheadReset({ search: "d " })), - expectNoOutMessage(), - model((next) => expect(next.search).toBe("d ")), - Command.resolve( - WaitForListBoxTypeaheadReset, - Message.CompletedWaitForTypeaheadReset({ search: "d " }), - ), - model((next) => expect(next.search).toBe("")), - ) - }) - - test("letters typed with a modifier do not start a search", () => { - story( - update, - given(focusedOn("dim")), - press("a", true), - Command.expectNone(), - model((next) => expect(next.search).toBe("")), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/list-box.ts b/apps/web-foldkit/src/ui/list-box.ts deleted file mode 100644 index d9538168e..000000000 --- a/apps/web-foldkit/src/ui/list-box.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { Array, Duration, Effect, Option, Schema, Stream } from "effect" -import { Command, Subscription, type Update } from "foldkit" -import * as Dom from "foldkit/dom" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { scrollIntoView } from "./aria/scroll" - -/** - * Port of `components/ui/list-box.tsx` (React Aria ListBox, standalone): selection, roving focus, - * typeahead, hover and press. Items render as ListBoxItem or DropdownItem (`dropdown.tsx`). - * The view lives in `list-box-view.ts`. - */ - -// MODEL - -export const Intent = Schema.Literals(["danger", "warning"]) -export type Intent = typeof Intent.Type - -export const Item = Schema.Struct({ - key: Schema.String, - textValue: Schema.String, - isDisabled: Schema.Boolean, - /** `Dropdown` renders DropdownItem (check indicator, intents); `ListBox` renders ListBoxItem. */ - flavor: Schema.Literals(["ListBox", "Dropdown"]), - intent: Schema.NullOr(Intent), - hasDescription: Schema.Boolean, -}) -export type Item = typeof Item.Type - -export const Entry = Schema.Union([ - Schema.TaggedStruct("Item", { item: Item }), - Schema.TaggedStruct("Section", { - title: Schema.NullOr(Schema.String), - /** `ListBoxSection` adds its own classes on top of DropdownSection's. */ - isListBoxSection: Schema.Boolean, - items: Schema.Array(Item), - }), - Schema.TaggedStruct("Separator", {}), -]) -export type Entry = typeof Entry.Type - -export const SelectionMode = Schema.Literals(["none", "single", "multiple"]) -export type SelectionMode = typeof SelectionMode.Type - -export const Modality = Schema.Literals(["Unknown", "Keyboard", "Pointer"]) - -export const Model = Schema.Struct({ - id: Schema.String, - entries: Schema.Array(Entry), - selectionMode: SelectionMode, - selectedKeys: Schema.Array(Schema.String), - /** The selection manager's focused key; it outlives focus leaving the list. */ - focusedKey: Schema.Option(Schema.String), - isFocusWithin: Schema.Boolean, - hoveredKey: Schema.Option(Schema.String), - pressedKey: Schema.Option(Schema.String), - modality: Modality, - search: Schema.String, -}) -export type Model = typeof Model.Type - -export const item = ( - key: string, - textValue: string, - options: Partial<Omit<Item, "key" | "textValue">> = {}, -): Item => ({ - key, - textValue, - isDisabled: options.isDisabled ?? false, - flavor: options.flavor ?? "ListBox", - intent: options.intent ?? null, - hasDescription: options.hasDescription ?? false, -}) - -export const entry = (value: Item): Entry => ({ _tag: "Item", item: value }) -export const section = ( - title: string | null, - items: ReadonlyArray<Item>, - options: { readonly isListBoxSection?: boolean } = {}, -): Entry => ({ _tag: "Section", title, isListBoxSection: options.isListBoxSection ?? true, items }) -export const separator: Entry = { _tag: "Separator" } - -export const init = (config: { - readonly id: string - readonly entries: ReadonlyArray<Entry> - readonly selectionMode?: SelectionMode - readonly selectedKeys?: ReadonlyArray<string> -}): Model => ({ - id: config.id, - entries: config.entries, - selectionMode: config.selectionMode ?? "none", - selectedKeys: config.selectedKeys ?? [], - focusedKey: Option.none(), - isFocusWithin: false, - hoveredKey: Option.none(), - pressedKey: Option.none(), - modality: "Unknown", - search: "", -}) - -// IDS - -export const listId = (id: string) => `${id}-listbox` -export const optionId = (id: string, key: string) => `${listId(id)}-option-${key}` -export const labelId = (id: string, key: string) => `${optionId(id, key)}-label` -export const descriptionId = (id: string, key: string) => `${optionId(id, key)}-description` - -// COLLECTION - -export const items = (model: Model): ReadonlyArray<Item> => - Array.flatMap(model.entries, (current) => - current._tag === "Item" ? [current.item] : current._tag === "Section" ? current.items : [], - ) - -const enabledKeys = (model: Model) => - Array.map( - Array.filter(items(model), (candidate) => !candidate.isDisabled), - (candidate) => candidate.key, - ) - -const findItem = (model: Model, key: string) => - Array.findFirst(items(model), (candidate) => candidate.key === key) - -/** ListKeyboardDelegate without wrapping, skipping disabled items. */ -const stepKey = (model: Model, direction: 1 | -1): Option.Option<string> => { - const keys = enabledKeys(model) - return Option.match( - Option.flatMap(model.focusedKey, (key) => - Array.findFirstIndex(keys, (candidate) => candidate === key), - ), - { - onNone: () => (direction === 1 ? Array.head(keys) : Array.last(keys)), - onSome: (index) => - Option.orElse(Array.get(keys, index + direction), () => Array.get(keys, index)), - }, - ) -} - -/** getKeyForSearch: from the focused item to the end, then from the start. */ -const searchKey = (model: Model, search: string): Option.Option<string> => { - const all = items(model) - const start = Option.getOrElse( - Option.flatMap(model.focusedKey, (key) => - Array.findFirstIndex(all, (candidate) => candidate.key === key), - ), - () => 0, - ) - const ordered = [...all.slice(start), ...all.slice(0, start)] - return Option.map( - Array.findFirst( - ordered, - (candidate) => - !candidate.isDisabled && - candidate.textValue.slice(0, search.length).toLowerCase() === search.toLowerCase(), - ), - (found) => found.key, - ) -} - -// MESSAGE - -export const Message = defineMessageUnion({ - FocusedList: { isFromAfter: Schema.Boolean }, - FocusedItem: { key: Schema.String }, - LeftList: {}, - HoveredItem: { key: Schema.String }, - UnhoveredItem: { key: Schema.String }, - PressedItem: { key: Schema.String, button: Schema.Number }, - ReleasedPointer: {}, - PressedKey: { key: Schema.String, isModified: Schema.Boolean }, - ReleasedKey: { key: Schema.String }, - CompletedFocusItem: {}, - CompletedWaitForTypeaheadReset: { search: Schema.String }, -}) -export type Message = typeof Message.Type - -export const OutMessage = defineMessageUnion({ - ChangedSelection: { keys: Schema.Array(Schema.String) }, -}) -export type OutMessage = typeof OutMessage.Type - -// COMMAND - -export const FocusListBoxItem = Command.define("FocusListBoxItem", { - args: { elementId: Schema.String }, - messages: [Message.CompletedFocusItem], - execute: ({ elementId }) => - Dom.focus(`#${CSS.escape(elementId)}`, { preventScroll: true }).pipe( - Effect.ignore, - // useSelectableCollection scrolls the focused option into the list's scroll port. - Effect.andThen( - Effect.sync(() => { - const element = document.getElementById(elementId) - const list = element?.closest("[role=listbox]") - if (element && list instanceof HTMLElement) scrollIntoView(list, element) - }), - ), - Effect.as(Message.CompletedFocusItem()), - ), -}) - -export const WaitForListBoxTypeaheadReset = Command.define("WaitForListBoxTypeaheadReset", { - args: { search: Schema.String }, - messages: [Message.CompletedWaitForTypeaheadReset], - execute: ({ search }) => - Effect.sleep(Duration.millis(1000)).pipe( - Effect.as(Message.CompletedWaitForTypeaheadReset({ search })), - ), -}) - -// UPDATE - -type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> - -const moveFocus = (model: Model, key: Option.Option<string>): UpdateReturn => - Option.match(key, { - onNone: () => ({ model }), - onSome: (found) => ({ - model: modifyFields(model, { focusedKey: () => Option.some(found) }), - commands: [FocusListBoxItem({ elementId: optionId(model.id, found) })], - }), - }) - -/** toggleSelection with the default `toggle` behavior; empty selection is allowed. */ -const toggle = (model: Model, key: string): UpdateReturn => { - if (model.selectionMode === "none" || Option.exists(findItem(model, key), (found) => found.isDisabled)) - return { model } - const isSelected = model.selectedKeys.includes(key) - const selectedKeys = - model.selectionMode === "single" - ? isSelected - ? [] - : [key] - : isSelected - ? model.selectedKeys.filter((selected) => selected !== key) - : [...model.selectedKeys, key] - return { - model: modifyFields(model, { selectedKeys: () => selectedKeys }), - outMessage: OutMessage.ChangedSelection({ keys: selectedKeys }), - } -} - -const firstSelected = (model: Model, fromEnd: boolean) => { - const selected = Array.filter(items(model), (candidate) => model.selectedKeys.includes(candidate.key)) - const keys = enabledKeys(model) - return fromEnd - ? Option.orElse( - Option.map(Array.last(selected), (found) => found.key), - () => Array.last(keys), - ) - : Option.orElse( - Option.map(Array.head(selected), (found) => found.key), - () => Array.head(keys), - ) -} - -const isPressKey = (key: string) => key === "Enter" || key === " " - -export const update = (model: Model, message: Message): UpdateReturn => - Message.match<UpdateReturn>(message, { - FocusedList: ({ isFromAfter }) => { - const focused = modifyFields(model, { isFocusWithin: () => true }) - return Option.isSome(model.focusedKey) - ? moveFocus(focused, model.focusedKey) - : moveFocus(focused, firstSelected(model, isFromAfter)) - }, - FocusedItem: ({ key }) => ({ - model: modifyFields(model, { isFocusWithin: () => true, focusedKey: () => Option.some(key) }), - }), - LeftList: () => ({ model: modifyFields(model, { isFocusWithin: () => false }) }), - HoveredItem: ({ key }) => ({ model: modifyFields(model, { hoveredKey: () => Option.some(key) }) }), - UnhoveredItem: ({ key }) => ({ - model: modifyFields(model, { hoveredKey: Option.filter((hovered) => hovered !== key) }), - }), - PressedItem: ({ key, button }) => { - if (button !== 0 || Option.exists(findItem(model, key), (found) => found.isDisabled)) - return { model } - const pressed = modifyFields(model, { - modality: () => "Pointer", - pressedKey: () => Option.some(key), - focusedKey: () => Option.some(key), - isFocusWithin: () => true, - }) - const selected = toggle(pressed, key) - return { ...selected, commands: [FocusListBoxItem({ elementId: optionId(model.id, key) })] } - }, - ReleasedPointer: () => ({ model: modifyFields(model, { pressedKey: () => Option.none() }) }), - PressedKey: ({ key, isModified }) => { - const keyed = modifyFields(model, { modality: () => "Keyboard" }) - if (key === "ArrowDown") return moveFocus(keyed, stepKey(keyed, 1)) - if (key === "ArrowUp") return moveFocus(keyed, stepKey(keyed, -1)) - if (key === "Home") return moveFocus(keyed, Array.head(enabledKeys(keyed))) - if (key === "End") return moveFocus(keyed, Array.last(enabledKeys(keyed))) - // Space types into an active typeahead search instead of selecting. - if (key === "Enter" || (key === " " && keyed.search === "")) { - return Option.match(keyed.focusedKey, { - onNone: () => ({ model: keyed }), - onSome: (focused) => - toggle(modifyFields(keyed, { pressedKey: () => Option.some(focused) }), focused), - }) - } - if (key.length !== 1 || isModified) return { model: keyed } - const search = keyed.search + key - const searched = moveFocus( - modifyFields(keyed, { search: () => search }), - searchKey(keyed, search), - ) - return { - ...searched, - commands: [...(searched.commands ?? []), WaitForListBoxTypeaheadReset({ search })], - } - }, - ReleasedKey: ({ key }) => - isPressKey(key) ? { model: modifyFields(model, { pressedKey: () => Option.none() }) } : { model }, - CompletedFocusItem: () => ({ model }), - CompletedWaitForTypeaheadReset: ({ search }) => - search === model.search ? { model: modifyFields(model, { search: () => "" }) } : { model }, - }) - -// SUBSCRIPTION - -/** usePress ends a pointer press on pointerup anywhere; always subscribed, so presses never restart it. */ -export const subscriptions = Subscription.make<Model, Message>()(() => ({ - pointerRelease: Subscription.persistentEntry( - Stream.suspend(() => - Dom.streamFromEvent({ - target: document, - type: "pointerup", - mapEvent: () => Message.ReleasedPointer(), - options: { capture: true }, - }), - ), - ), -})) diff --git a/apps/web-foldkit/src/ui/menu-view.ts b/apps/web-foldkit/src/ui/menu-view.ts index 29471b098..0edb3e1cd 100644 --- a/apps/web-foldkit/src/ui/menu-view.ts +++ b/apps/web-foldkit/src/ui/menu-view.ts @@ -10,14 +10,11 @@ import { import { twMerge } from "tailwind-merge" import { dropdownCheckIndicatorClassName, - dropdownDescriptionBase, dropdownItemStyles, - dropdownKeyboardBase, dropdownLabelBase, dropdownSectionStyles, dropdownSeparatorBase, } from "~/components/ui/dropdown.styles" -import { keyboardStyles } from "~/components/ui/keyboard.styles" import { menuChevronClassName, menuContentStyles, @@ -206,29 +203,6 @@ export const menuLabel = <M>( typeof text === "string" ? [text] : [...text], ) -/** `MenuDescription` (DropdownDescription). */ -export const menuDescription = <M>(h: HtmlBuilder<M>, id: string, key: string, text: string): Html => - h.span( - [ - h.Class(twMerge(dropdownDescriptionBase)), - h.Id(descriptionId(id, key)), - h.Attribute("slot", "description"), - ], - [text], - ) - -/** `MenuShortcut` (DropdownKeyboard over Keyboard). */ -export const menuShortcut = <M>(h: HtmlBuilder<M>, id: string, key: string, text: string): Html => - h.kbd( - [ - h.Class(twMerge(keyboardStyles, twMerge(dropdownKeyboardBase))), - h.Attribute("data-slot", "keyboard"), - h.Attribute("dir", "ltr"), - h.Id(descriptionId(id, key)), - ], - [text], - ) - /** `MenuTrigger`'s classes, for an icon trigger that is not a `Button`. */ export const menuTriggerClassName = (className?: string) => twMerge(twMerge(...menuTriggerBase), className) diff --git a/apps/web-foldkit/src/ui/progress-bar.ts b/apps/web-foldkit/src/ui/progress-bar.ts index 6c34f043b..55ae902e3 100644 --- a/apps/web-foldkit/src/ui/progress-bar.ts +++ b/apps/web-foldkit/src/ui/progress-bar.ts @@ -36,24 +36,6 @@ export const progressBar = <Message>( [...children], ) -export const progressBarHeader = <Message>( - h: HtmlBuilder<Message>, - options: { readonly className?: string }, - children: ReadonlyArray<Html | string>, -): Html => - h.div( - [ - h.Attribute("data-slot", "progress-bar-header"), - h.Class(twMerge(progressBarStyles.header, options.className)), - ], - [...children], - ) - -export const progressBarValue = <Message>( - h: HtmlBuilder<Message>, - options: ProgressBarState & { readonly className?: string }, -): Html => h.span([h.Class(twMerge(progressBarStyles.value, options.className))], [valueText(options)]) - export const progressBarTrack = <Message>( h: HtmlBuilder<Message>, options: ProgressBarState & { readonly className?: string }, diff --git a/apps/web-foldkit/src/ui/section-footer.ts b/apps/web-foldkit/src/ui/section-footer.ts deleted file mode 100644 index 918abc2d0..000000000 --- a/apps/web-foldkit/src/ui/section-footer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { sectionFooterStyles } from "~/components/ui/section-footer.styles" - -/** Port of `components/ui/section-footer.tsx`. */ -export const sectionFooterRoot = <Message>( - h: HtmlBuilder<Message>, - options: { readonly isCard?: boolean; readonly className?: string }, - children: ReadonlyArray<Html | string>, -): Html => - h.div( - [ - h.Class( - twMerge( - sectionFooterStyles.root, - options.isCard ? sectionFooterStyles.rootCard : sectionFooterStyles.rootPlain, - options.className, - ), - ), - ], - [...children], - ) - -export const sectionFooterActions = <Message>( - h: HtmlBuilder<Message>, - options: { readonly className?: string }, - children: ReadonlyArray<Html | string>, -): Html => h.div([h.Class(twMerge(sectionFooterStyles.actions, options.className))], [...children]) diff --git a/apps/web-foldkit/src/ui/section-label.ts b/apps/web-foldkit/src/ui/section-label.ts index 9d52bb0b7..1d0bab4f4 100644 --- a/apps/web-foldkit/src/ui/section-label.ts +++ b/apps/web-foldkit/src/ui/section-label.ts @@ -45,8 +45,3 @@ export const sectionLabelRoot = <Message>( ]) } -export const sectionLabelActions = <Message>( - h: HtmlBuilder<Message>, - options: { readonly className?: string }, - children: ReadonlyArray<Html | string>, -): Html => h.div([h.Class(twMerge(sectionLabelStyles.actions, options.className))], [...children]) diff --git a/apps/web-foldkit/src/ui/table-view.ts b/apps/web-foldkit/src/ui/table-view.ts deleted file mode 100644 index 906cb01aa..000000000 --- a/apps/web-foldkit/src/ui/table-view.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { Array, Option } from "effect" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" -import { defineView } from "foldkit/submodel" -import { twJoin, twMerge } from "tailwind-merge" -import { tableStyles } from "~/components/ui/table.styles" -import { IconChevronDown } from "../icons" -import * as Collection from "./aria/collection" -import * as Interaction from "./aria/interaction" -import { checkbox } from "./checkbox" -import { cellId, columnId, Message, type Model, rowId, SELECTION_COLUMN } from "./table" - -export interface TableColumn { - readonly key: string - readonly content: string - readonly isRowHeader?: boolean - readonly allowsSorting?: boolean -} - -export interface TableRow { - readonly key: string - readonly cells: ReadonlyArray<Html | string> - readonly isDisabled?: boolean -} - -export type ViewInputs = Readonly<{ - label: string - columns: ReadonlyArray<TableColumn> - rows: ReadonlyArray<TableRow> - emptyState?: ReadonlyArray<Html | string> - className?: string - bleed?: boolean - grid?: boolean - striped?: boolean -}> - -const rac = <Message>(h: HtmlBuilder<Message>, slot?: string): ReadonlyArray<Attribute<Message>> => [ - h.Attribute("data-rac", ""), - ...(slot === undefined ? [] : [h.Attribute("data-slot", slot)]), -] - -export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const { focus, modality } = model - const { columns, rows, bleed, grid, striped } = viewInputs - const isSelectable = model.selectionMode !== "none" - const isMultiple = model.selectionMode === "multiple" - const columnOffset = isMultiple ? 1 : 0 - const wiring: Interaction.Wiring<Message> = { - model: model.interaction, - toParentMessage: (message) => Message.GotInteractionMessage({ message }), - } - const hasRows = Array.isReadonlyArrayNonEmpty(rows) - const isVisible = modality !== "Pointer" - const isFocusWithin = focus._tag !== "Outside" - const enabledRows = Array.filter(rows, (row) => row.isDisabled !== true) - const rowKeys = Array.map(rows, (row) => row.key) - const isDisabledRow = (key: string) => !Array.some(enabledRows, (row) => row.key === key) - // useTable's getRowText: the row header cell (isRowHeader, else the first column) when it is text. - const rowHeaderIndex = Math.max( - 0, - columns.findIndex((column) => column.isRowHeader === true), - ) - const rowText = (key: string) => { - const cell = rows.find((row) => row.key === key)?.cells[rowHeaderIndex] - return typeof cell === "string" ? cell : "" - } - const firstColumnKey = Option.getOrElse( - Option.map(Array.head(columns), (column) => column.key), - () => "", - ) - const firstEnabledRow = Option.map(Array.head(enabledRows), (row) => row.key) - const tableFocusTarget = Option.orElse( - Array.findFirst(model.selectedKeys, (key) => !isDisabledRow(key)), - () => firstEnabledRow, - ) - - const verticalTarget = (fromRow: string, keyboardKey: string) => - Option.flatMap(Collection.directionOfKey("vertical", keyboardKey), (direction) => - Collection.moveKey(rowKeys, fromRow, direction, isDisabledRow, false), - ) - - const rowNavigation = (fromRow: string) => (keyboardKey: string) => - isMultiple && keyboardKey === " " - ? Option.some({ - focusSelector: Collection.idSelector(rowId(model, fromRow)), - message: Message.PressedRowSpace({ row: fromRow, rowText: rowText(fromRow) }), - }) - : Option.map(verticalTarget(fromRow, keyboardKey), (row) => ({ - focusSelector: Collection.idSelector(rowId(model, row)), - message: Message.NavigatedToRow({ row, isSelectable, rowText: rowText(row) }), - })) - - // NOTE: keydown bubbles from rows and cells (which may already have moved focus), so this reads the - // live focus: it only acts while the table element itself is focused, before RA-style redirection. - const tableNavigation = (targetRow: string) => (keyboardKey: string) => - document.activeElement?.id === model.id ? rowNavigation(targetRow)(keyboardKey) : Option.none() - - const cellNavigation = (fromRow: string, fromColumn: string) => (keyboardKey: string) => { - const columnKeys = Array.map(columns, (column) => column.key) - const maybeColumn = Option.flatMap( - Collection.directionOfKey("horizontal", keyboardKey), - (direction) => - direction === "Next" || direction === "Previous" - ? Collection.moveKey(columnKeys, fromColumn, direction, () => false, false) - : Option.none(), - ) - const maybeTarget = Option.orElse( - Option.map(maybeColumn, (column) => ({ row: fromRow, column })), - () => Option.map(verticalTarget(fromRow, keyboardKey), (row) => ({ row, column: fromColumn })), - ) - return Option.map(maybeTarget, ({ row, column }) => ({ - focusSelector: Collection.idSelector(cellId(model, row, column)), - message: Message.NavigatedToRow({ row, isSelectable, rowText: rowText(row) }), - })) - } - - const columnView = (column: TableColumn, index: number): Html => { - const isHovered = Option.contains(model.maybeHoveredColumn, column.key) - const isFocused = focus._tag === "Column" && focus.column === column.key - const maybeDirection = Option.flatMap(model.maybeSort, (sort) => - sort.column === column.key ? Option.some(sort.direction) : Option.none(), - ) - const sortDirection = Option.getOrUndefined(maybeDirection) - const sortIconClass = tableStyles.sortIcon(sortDirection) - return h.keyed("th")( - column.key, - [ - h.Class(twMerge(twMerge(tableStyles.column({ bleed, grid, isResizable: false })))), - h.Id(columnId(model, column.key)), - h.Role("columnheader"), - h.Attribute("aria-colindex", String(index + 1 + columnOffset)), - ...(column.allowsSorting ? [h.Attribute("aria-sort", sortDirection ?? "none")] : []), - ...(column.allowsSorting ? [h.Attribute("data-allows-sorting", "true")] : []), - ...(sortDirection === undefined ? [] : [h.Attribute("data-sort-direction", sortDirection)]), - h.Attribute("data-collection", model.id), - h.Attribute("data-key", column.key), - ...rac(h, "table-column"), - h.Attribute("data-react-aria-pressable", "true"), - h.Tabindex(isFocused ? 0 : -1), - ...Collection.stateAttributes(h, { - isHovered, - isFocused, - isFocusVisible: isFocused && isVisible, - }), - h.OnMouseEnter(Message.HoveredColumn({ column: column.key })), - h.OnMouseLeave(Message.UnhoveredColumn({ column: column.key })), - h.OnFocus(Message.FocusedColumn({ column: column.key })), - h.OnBlur(Message.BlurredTable()), - ...(column.allowsSorting ? [h.OnClick(Message.ClickedColumn({ column: column.key }))] : []), - ], - [ - h.div( - [h.Class(twJoin(tableStyles.columnContent))], - [ - column.content, - ...(column.allowsSorting - ? [ - h.span( - [h.Class(twJoin(...tableStyles.sortIndicator(isHovered)))], - [ - IconChevronDown( - h, - sortIconClass - ? { className: sortIconClass } - : { attributes: { class: "" } }, - ), - ], - ), - ] - : []), - ], - ), - ], - ) - } - - const cellClass = twMerge( - twMerge(twJoin(...tableStyles.cell({ allowResize: false, bleed, grid, striped }))), - ) - - const rowHeaderKey = Option.getOrElse( - Option.map( - Array.findFirst(columns, (column) => column.isRowHeader === true), - (column) => column.key, - ), - () => firstColumnKey, - ) - - /** TableRow's leading cell with `<Checkbox slot="selection" />`; the row's press toggles it. */ - const selectionCell = (row: TableRow, isSelected: boolean, isDisabled: boolean): Html => { - const checkboxId = `${rowId(model, row.key)}-select` - return h.keyed("td")( - SELECTION_COLUMN, - [ - h.Class( - twMerge( - twMerge(twJoin(...tableStyles.cell({ allowResize: false, bleed, grid, striped }))), - tableStyles.dragCell, - ), - ), - h.Id(cellId(model, row.key, SELECTION_COLUMN)), - h.Role("gridcell"), - h.Attribute("data-collection", model.id), - h.Attribute("data-column-index", "0"), - h.Attribute("data-key", `${row.key}-${SELECTION_COLUMN}`), - ...rac(h, "table-cell"), - h.Tabindex(-1), - ...Collection.stateAttributes(h, { isSelected }), - ], - [ - checkbox( - h, - { - id: checkboxId, - isSelected, - isDisabled, - slot: "selection", - ariaLabel: "Select", - labelledBy: `${checkboxId} ${cellId(model, row.key, rowHeaderKey)}`, - interaction: wiring, - }, - [], - ), - ], - ) - } - - const enabledKeys = Array.map(enabledRows, (row) => row.key) - const selectedEnabled = Array.filter(enabledKeys, (key) => model.selectedKeys.includes(key)) - const isAllSelected = enabledKeys.length > 0 && selectedEnabled.length === enabledKeys.length - const selectionColumn = h.keyed("th")( - SELECTION_COLUMN, - [ - h.Class(twMerge(...tableStyles.utilityColumn(bleed))), - h.Id(columnId(model, SELECTION_COLUMN)), - h.Role("columnheader"), - h.Attribute("aria-colindex", "1"), - h.Attribute("data-collection", model.id), - h.Attribute("data-key", SELECTION_COLUMN), - ...rac(h, "table-column"), - h.Attribute("data-react-aria-pressable", "true"), - h.Tabindex(focus._tag === "Column" && focus.column === SELECTION_COLUMN ? 0 : -1), - h.OnFocusEnter(Message.FocusedColumn({ column: SELECTION_COLUMN })), - ], - [ - checkbox( - h, - { - id: `${model.id}-select-all`, - isSelected: isAllSelected, - isIndeterminate: !isAllSelected && selectedEnabled.length > 0, - slot: "selection", - ariaLabel: "Select All", - interaction: wiring, - onChange: () => Message.ToggledAll({ keys: enabledKeys }), - }, - [], - ), - ], - ) - - const rowView = (row: TableRow): Html => { - const isDisabled = row.isDisabled === true - const isSelected = isSelectable && model.selectedKeys.includes(row.key) - const isRowFocused = focus._tag === "Row" && focus.row === row.key - const isWithin = (focus._tag === "Row" || focus._tag === "Cell") && focus.row === row.key - const isPressable = isSelectable && !isDisabled - const rowClass = twMerge( - ...tableStyles.row({ - isSelected, - isFocusVisible: isRowFocused && isVisible, - isFocusVisibleWithin: isWithin && isVisible, - isDragging: false, - isDisabled, - isActionable: isMultiple, - striped, - }), - ) - const cellView = (cell: Html | string, index: number): Html => { - const column = Array.get(columns, index) - const columnKey = Option.getOrElse( - Option.map(column, (it) => it.key), - () => String(index), - ) - const isRowHeader = Option.exists(column, (it) => it.isRowHeader === true) - const isFocused = focus._tag === "Cell" && focus.row === row.key && focus.column === columnKey - return h.keyed("td")( - columnKey, - [ - h.Class(cellClass), - h.Id(cellId(model, row.key, columnKey)), - h.Role(isRowHeader ? "rowheader" : "gridcell"), - h.Attribute("data-collection", model.id), - h.Attribute("data-column-index", String(index + columnOffset)), - h.Attribute("data-key", `${row.key}-${columnKey}`), - ...rac(h, "table-cell"), - h.Tabindex(isFocused ? 0 : -1), - ...Collection.stateAttributes(h, { - isFocused, - isFocusVisible: isFocused && isVisible, - isSelected, - }), - h.OnFocus(Message.FocusedCell({ row: row.key, column: columnKey })), - h.OnBlur(Message.BlurredTable()), - h.OnKeyDownFocus(cellNavigation(row.key, columnKey)), - ], - [cell], - ) - } - const interactive: ReadonlyArray<Attribute<Message>> = isDisabled - ? [h.AriaDisabled(true)] - : [ - h.Tabindex(isRowFocused ? 0 : -1), - h.OnFocus(Message.FocusedRow({ row: row.key })), - h.OnBlur(Message.BlurredTable()), - h.OnKeyDownFocus((keyboardKey) => - document.activeElement?.id === rowId(model, row.key) - ? rowNavigation(row.key)(keyboardKey) - : Option.none(), - ), - h.OnPointerDown(() => - Option.some( - Message.PressedRow({ row: row.key, isSelectable, rowText: rowText(row.key) }), - ), - ), - h.OnMouseEnter(Message.HoveredRow({ row: row.key })), - h.OnMouseLeave(Message.UnhoveredRow({ row: row.key })), - ] - return h.keyed("tr")( - row.key, - [ - h.Class(rowClass), - h.Id(rowId(model, row.key)), - h.Role("row"), - h.AriaLabelledBy(cellId(model, row.key, firstColumnKey)), - ...(isSelectable - ? [h.AriaSelected(isSelected), h.Attribute("data-selection-mode", model.selectionMode)] - : []), - h.Attribute("data-collection", model.id), - h.Attribute("data-key", row.key), - ...rac(h, "table-row"), - ...(isPressable ? [h.Attribute("data-react-aria-pressable", "true")] : []), - ...Collection.stateAttributes(h, { - isHovered: isPressable && Option.contains(model.maybeHoveredRow, row.key), - isFocused: isRowFocused, - isFocusVisible: isRowFocused && isVisible, - isSelected, - isDisabled, - }), - ...(isWithin && isVisible ? [h.DataAttribute("focus-visible-within", "true")] : []), - ...interactive, - ], - [ - ...(isMultiple ? [selectionCell(row, isSelected, isDisabled)] : []), - ...Array.map(row.cells, cellView), - ], - ) - } - - const emptyRow = h.tr( - [h.Role("row")], - [ - h.td( - [h.Attribute("colspan", String(columns.length)), h.Role("rowheader")], - [...(viewInputs.emptyState ?? [])], - ), - ], - ) - - const table = h.table( - [ - h.Class(tableStyles.root), - h.Id(model.id), - h.Role("grid"), - h.AriaLabel(viewInputs.label), - h.Attribute("aria-describedby", ""), - ...(hasRows ? [h.Attribute("data-collection", model.id)] : []), - h.Attribute("data-rac", ""), - h.Tabindex(isFocusWithin ? -1 : 0), - ...Option.match(tableFocusTarget, { - onNone: () => [], - onSome: (targetRow) => [ - h.OnFocus(Message.FocusedTable({ targetRow })), - h.OnKeyDownFocus(tableNavigation(targetRow)), - ], - }), - h.OnKeyUp(() => Message.ReleasedKey()), - ], - [ - h.thead( - [ - h.Class(twMerge(twMerge(tableStyles.header))), - ...rac(h, "table-header"), - h.Role("rowgroup"), - ...Collection.stateAttributes(h, { isHovered: Option.isSome(model.maybeHoveredColumn) }), - ], - [ - h.tr( - [h.Role("row")], - [...(isMultiple ? [selectionColumn] : []), ...Array.map(columns, columnView)], - ), - ], - ), - h.tbody( - [ - h.Class("react-aria-TableBody"), - ...(hasRows ? [] : [h.DataAttribute("empty", "true")]), - ...rac(h, "table-body"), - h.Role("rowgroup"), - ], - hasRows ? Array.map(rows, rowView) : [emptyRow], - ), - ], - ) - - const focusScope = (edge: "start" | "end") => - h.span([h.Attribute(`data-focus-scope-${edge}`, "true"), h.Attribute("hidden", "")]) - - return h.div( - [h.Class("flow-root")], - [ - h.div( - [h.Class(twMerge(tableStyles.scroller, viewInputs.className))], - [ - h.div( - [h.Class(twJoin(...tableStyles.inner(bleed)))], - [focusScope("start"), table, focusScope("end")], - ), - ], - ), - ], - ) -}) diff --git a/apps/web-foldkit/src/ui/table.scene.test.ts b/apps/web-foldkit/src/ui/table.scene.test.ts deleted file mode 100644 index 9c4bad83c..000000000 --- a/apps/web-foldkit/src/ui/table.scene.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// @vitest-environment jsdom -import * as Scene from "foldkit/scene" -import { describe, test } from "vitest" -import { installCssEscape } from "../test/kit-collections-fixtures" -import { AnnounceTable, init, Message, update } from "./table" -import { view } from "./table-view" - -installCssEscape() - -/** Table through the view: grid, columnheader aria-sort, row aria-selected, select all, keyboard. */ - -const sceneView = Scene.withViewInputs(view, { - label: "Members", - columns: [ - { key: "name", content: "Name", isRowHeader: true, allowsSorting: true }, - { key: "role", content: "Role" }, - ], - rows: [ - { key: "ada", cells: ["Ada Lovelace", "Owner"] }, - { key: "grace", cells: ["Grace Hopper", "Admin"] }, - { key: "katherine", cells: ["Katherine Johnson", "Guest"], isDisabled: true }, - { key: "alan", cells: ["Alan Turing", "Member"] }, - ], -}) -const config = { update, view: sceneView() } -const row = (name: string) => Scene.role("row", { name }) -const column = (name: string) => Scene.role("columnheader", { name: new RegExp(`^${name}`) }) -const resolveAnnouncement = Scene.Command.resolve(AnnounceTable, Message.CompletedAnnounce()) - -describe("table scene", () => { - test("renders a labelled grid with sortable headers, row headers and disabled rows", () => { - Scene.scene( - config, - Scene.given(init({ id: "members" })), - Scene.expect(Scene.role("grid", { name: "Members" })).toExist(), - Scene.expect(column("Name")).toHaveAttr("aria-sort", "none"), - Scene.expect(column("Role")).not.toHaveAttr("aria-sort"), - Scene.expect(Scene.role("rowheader", { name: "Ada Lovelace" })).toExist(), - Scene.expect(row("Katherine Johnson")).toHaveAttr("aria-disabled", "true"), - Scene.expect(row("Ada Lovelace")).not.toHaveAttr("aria-selected"), - ) - }) - - test("clicking a sortable header sorts ascending, then descending", () => { - Scene.scene( - config, - Scene.given(init({ id: "members" })), - Scene.click(column("Name")), - resolveAnnouncement, - Scene.expect(column("Name")).toHaveAttr("aria-sort", "ascending"), - Scene.click(column("Name")), - resolveAnnouncement, - Scene.expect(column("Name")).toHaveAttr("aria-sort", "descending"), - Scene.expect(column("Name")).toHaveAttr("data-sort-direction", "descending"), - ) - }) - - test("a header that does not sort has no click handler", () => { - Scene.scene(config, Scene.given(init({ id: "members" })), Scene.expect(column("Role")).not.toHaveHandler("click")) - }) - - test("pressing a row in single mode selects it and announces its row header", () => { - Scene.scene( - config, - Scene.given(init({ id: "members", selectionMode: "single" })), - Scene.pointerDown(row("Grace Hopper")), - Scene.Command.expectExact(AnnounceTable({ message: "Grace Hopper selected.", timeout: 7000 })), - resolveAnnouncement, - Scene.expect(row("Grace Hopper")).toHaveAttr("aria-selected", "true"), - Scene.expect(row("Ada Lovelace")).toHaveAttr("aria-selected", "false"), - Scene.expect(row("Katherine Johnson")).not.toHaveHandler("pointerdown"), - ) - }) - - test("ArrowDown from a cell skips the disabled row and selects the next one in single mode", () => { - Scene.scene( - config, - Scene.given(init({ id: "members", selectionMode: "single", selectedKeys: ["grace"] })), - Scene.keydown(Scene.role("gridcell", { name: "Admin" }), "ArrowDown"), - Scene.Command.expectExact(AnnounceTable({ message: "Alan Turing selected.", timeout: 7000 })), - resolveAnnouncement, - Scene.expect(row("Alan Turing")).toHaveAttr("aria-selected", "true"), - Scene.expect(row("Grace Hopper")).toHaveAttr("aria-selected", "false"), - ) - }) - - test("multiple mode adds a Select All header that is indeterminate while some rows are selected", () => { - Scene.scene( - config, - Scene.given(init({ id: "invite", selectionMode: "multiple", selectedKeys: ["grace"] })), - Scene.expect(Scene.role("checkbox", { name: "Select All" })).not.toBeChecked(), - Scene.expect(Scene.selector("#invite-__selection label")).toHaveAttr("data-indeterminate", "true"), - Scene.expect(row("Grace Hopper")).toHaveAttr("aria-selected", "true"), - Scene.expect(Scene.selector("#invite-row-grace-select")).toBeChecked(), - Scene.expect(Scene.selector("#invite-row-katherine-select")).toBeDisabled(), - ) - }) - - test("Select All selects every enabled row but leaves the disabled one alone", () => { - Scene.scene( - config, - Scene.given(init({ id: "invite", selectionMode: "multiple", selectedKeys: ["grace"] })), - Scene.click(Scene.selector("#invite-__selection label")), - Scene.Command.expectExact(AnnounceTable({ message: "All items selected.", timeout: 7000 })), - resolveAnnouncement, - Scene.expect(Scene.role("checkbox", { name: "Select All" })).toBeChecked(), - Scene.expect(row("Ada Lovelace")).toHaveAttr("aria-selected", "true"), - Scene.expect(row("Alan Turing")).toHaveAttr("aria-selected", "true"), - Scene.expect(row("Katherine Johnson")).toHaveAttr("aria-selected", "false"), - ) - }) - - test("pressing a row in multiple mode toggles it without touching the others", () => { - Scene.scene( - config, - Scene.given(init({ id: "invite", selectionMode: "multiple", selectedKeys: ["grace"] })), - Scene.pointerDown(row("Ada Lovelace")), - Scene.Command.expectExact(AnnounceTable({ message: "Ada Lovelace selected. 2 items selected.", timeout: 7000 })), - resolveAnnouncement, - Scene.expect(row("Ada Lovelace")).toHaveAttr("aria-selected", "true"), - Scene.expect(row("Grace Hopper")).toHaveAttr("aria-selected", "true"), - ) - }) - - test("a table without rows shows its empty state", () => { - Scene.scene( - { update, view: sceneView({ rows: [], emptyState: ["No members yet"] }) }, - Scene.given(init({ id: "empty" })), - Scene.expect(Scene.role("rowheader")).toHaveText("No members yet"), - Scene.expectAll(Scene.all.role("row")).toHaveCount(2), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/table.story.test.ts b/apps/web-foldkit/src/ui/table.story.test.ts deleted file mode 100644 index 31b44e336..000000000 --- a/apps/web-foldkit/src/ui/table.story.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Option } from "effect" -import { Command, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { AnnounceTable, FocusRowFromTable, init, Message, sortOf, update } from "./table" - -/** React Aria Table: replace vs toggle selection, select all, sorting, grid focus and announcements. */ - -const members = init({ id: "members", selectionMode: "single" }) -const invite = init({ id: "invite", selectionMode: "multiple", selectedKeys: ["grace"] }) -const selected = (text: string) => AnnounceTable({ message: text, timeout: 7000 }) -const sorted = (direction: string) => - AnnounceTable({ message: `sorted by column in ${direction} order`, timeout: 500 }) -const press = (row: string, rowText: string) => - message(Message.PressedRow({ row, isSelectable: true, rowText })) - -describe("table story", () => { - test("pressing a row in single mode replaces the selection and announces the row", () => { - story( - update, - given({ ...members, selectedKeys: ["grace"] }), - press("ada", "Ada Lovelace"), - Command.expectExact(selected("Ada Lovelace selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => { - expect(next.selectedKeys).toEqual(["ada"]) - expect(next.modality).toBe("Pointer") - }), - ) - }) - - test("arrow navigation in single mode moves the selection with focus", () => { - story( - update, - given(members), - message(Message.NavigatedToRow({ row: "alan", isSelectable: true, rowText: "Alan Turing" })), - Command.expectExact(selected("Alan Turing selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => { - expect(next.selectedKeys).toEqual(["alan"]) - expect(next.modality).toBe("Keyboard") - }), - ) - }) - - test("arrow navigation in multiple mode only moves focus; Space toggles the row", () => { - story( - update, - given(invite), - message(Message.NavigatedToRow({ row: "ada", isSelectable: true, rowText: "Ada Lovelace" })), - Command.expectNone(), - model((next) => expect(next.selectedKeys).toEqual(["grace"])), - message(Message.PressedRowSpace({ row: "ada", rowText: "Ada Lovelace" })), - Command.expectExact(selected("Ada Lovelace selected. 2 items selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => expect(next.selectedKeys).toEqual(["grace", "ada"])), - ) - }) - - test("pressing a selected row in multiple mode deselects it", () => { - story( - update, - given(invite), - press("grace", "Grace Hopper"), - Command.expectExact(selected("Grace Hopper not selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => expect(next.selectedKeys).toEqual([])), - ) - }) - - test("Select All selects every enabled row, and a second press clears them", () => { - const keys = ["ada", "grace", "alan"] - story( - update, - given(invite), - message(Message.ToggledAll({ keys })), - Command.expectExact(selected("All items selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => expect(next.selectedKeys).toEqual(keys)), - message(Message.ToggledAll({ keys })), - Command.expectExact(selected("No items selected.")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => expect(next.selectedKeys).toEqual([])), - ) - }) - - test("rows that are not selectable, or tables without selection, never change selection", () => { - story( - update, - given(init({ id: "plain" })), - press("ada", "Ada Lovelace"), - Command.expectNone(), - message(Message.PressedRow({ row: "ada", isSelectable: false, rowText: "Ada Lovelace" })), - Command.expectNone(), - model((next) => expect(next.selectedKeys).toEqual([])), - ) - }) - - test("clicking a sortable column sorts ascending, then flips direction, announcing each change", () => { - story( - update, - given(members), - message(Message.ClickedColumn({ column: "name" })), - Command.expectExact(sorted("ascending")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => - expect(sortOf(next)).toEqual(Option.some({ column: "name", direction: "ascending" })), - ), - message(Message.ClickedColumn({ column: "name" })), - Command.expectExact(sorted("descending")), - Command.resolve(AnnounceTable, Message.CompletedAnnounce()), - model((next) => - expect(sortOf(next)).toEqual(Option.some({ column: "name", direction: "descending" })), - ), - ) - }) - - test("sorting another column in the same direction is silent, since the column name is never announced", () => { - story( - update, - given(init({ id: "members", sort: { column: "name", direction: "ascending" } })), - message(Message.ClickedColumn({ column: "channels" })), - Command.expectNone(), - model((next) => - expect(sortOf(next)).toEqual(Option.some({ column: "channels", direction: "ascending" })), - ), - ) - }) - - test("focusing the table redirects focus to the target row", () => { - story( - update, - given(members), - message(Message.FocusedTable({ targetRow: "ada" })), - Command.expectExact(FocusRowFromTable({ tableId: "members", rowElementId: "members-row-ada" })), - Command.resolve(FocusRowFromTable, Message.CompletedFocusRow()), - ) - }) - - test("focus moves between rows, cells and column headers and leaves on blur", () => { - story( - update, - given(members), - message(Message.FocusedRow({ row: "ada" })), - model((next) => expect(next.focus).toEqual({ _tag: "Row", row: "ada" })), - message(Message.FocusedCell({ row: "ada", column: "role" })), - model((next) => expect(next.focus).toEqual({ _tag: "Cell", row: "ada", column: "role" })), - message(Message.FocusedColumn({ column: "name" })), - model((next) => expect(next.focus).toEqual({ _tag: "Column", column: "name" })), - message(Message.BlurredTable()), - model((next) => expect(next.focus).toEqual({ _tag: "Outside" })), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/table.ts b/apps/web-foldkit/src/ui/table.ts deleted file mode 100644 index e17f1a8ac..000000000 --- a/apps/web-foldkit/src/ui/table.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { Effect, Option, Schema } from "effect" -import { Subscription, type Update } from "foldkit" -import * as Command from "foldkit/command" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { announce, gridSelectionMessage } from "./aria/announcer" -import * as Collection from "./aria/collection" -import * as Interaction from "./aria/interaction" - -/** - * Port of `components/ui/table.tsx` (React Aria Table): row selection (single with replace - * behavior, multiple with toggle behavior and selection checkboxes), sortable columns, grid - * focus (rows, cells, column headers) and arrow-key navigation. The view lives in `table-view.ts`. - */ - -// MODEL - -export const SortDirection = Schema.Literals(["ascending", "descending"]) -export type SortDirection = typeof SortDirection.Type - -export const SortDescriptor = Schema.Struct({ column: Schema.String, direction: SortDirection }) -export type SortDescriptor = typeof SortDescriptor.Type - -export const SelectionMode = Schema.Literals(["none", "single", "multiple"]) -export type SelectionMode = typeof SelectionMode.Type - -export const Focus = Schema.Union([ - Schema.TaggedStruct("Outside", {}), - Schema.TaggedStruct("Row", { row: Schema.String }), - Schema.TaggedStruct("Cell", { row: Schema.String, column: Schema.String }), - Schema.TaggedStruct("Column", { column: Schema.String }), -]) -export type Focus = typeof Focus.Type - -export const Model = Schema.Struct({ - id: Schema.String, - selectionMode: SelectionMode, - selectedKeys: Schema.Array(Schema.String), - maybeSort: Schema.Option(SortDescriptor), - focus: Focus, - maybeHoveredRow: Schema.Option(Schema.String), - maybeHoveredColumn: Schema.Option(Schema.String), - modality: Collection.Modality, - /** Hover, press and focus of the selection checkboxes. */ - interaction: Interaction.Model, -}) -export type Model = typeof Model.Type - -export const init = (config: { - readonly id: string - readonly selectionMode?: SelectionMode - readonly selectedKeys?: ReadonlyArray<string> - readonly sort?: SortDescriptor -}): Model => ({ - id: config.id, - selectionMode: config.selectionMode ?? "none", - selectedKeys: config.selectedKeys ?? [], - maybeSort: Option.fromNullishOr(config.sort), - focus: { _tag: "Outside" }, - maybeHoveredRow: Option.none(), - maybeHoveredColumn: Option.none(), - modality: "Unknown", - interaction: Interaction.init(), -}) - -/** The current sort, for the parent to order its rows (RA's controlled `sortDescriptor`). */ -export const sortOf = (model: Model): Option.Option<SortDescriptor> => model.maybeSort - -// MESSAGE - -export const Message = defineMessageUnion({ - HoveredRow: { row: Schema.String }, - UnhoveredRow: { row: Schema.String }, - HoveredColumn: { column: Schema.String }, - UnhoveredColumn: { column: Schema.String }, - PressedRow: { row: Schema.String, isSelectable: Schema.Boolean, rowText: Schema.String }, - ClickedColumn: { column: Schema.String }, - FocusedTable: { targetRow: Schema.String }, - FocusedRow: { row: Schema.String }, - FocusedCell: { row: Schema.String, column: Schema.String }, - FocusedColumn: { column: Schema.String }, - BlurredTable: {}, - NavigatedToRow: { row: Schema.String, isSelectable: Schema.Boolean, rowText: Schema.String }, - PressedRowSpace: { row: Schema.String, rowText: Schema.String }, - ToggledAll: { keys: Schema.Array(Schema.String) }, - GotInteractionMessage: { message: Interaction.Message }, - ReleasedKey: {}, - CompletedFocusRow: {}, - CompletedAnnounce: {}, -}) -export type Message = typeof Message.Type - -// COMMAND - -export const rowId = (model: Model, row: string) => `${model.id}-row-${row}` -export const cellId = (model: Model, row: string, column: string) => `${model.id}-${row}-${column}` -export const columnId = (model: Model, column: string) => `${model.id}-${column}` - -// NOTE: a key press can move focus off the table before this runs; only redirect if it hasn't. -export const FocusRowFromTable = Command.define("FocusRowFromTable", { - args: { tableId: Schema.String, rowElementId: Schema.String }, - messages: [Message.CompletedFocusRow], - execute: ({ tableId, rowElementId }) => - Effect.sync(() => { - if (document.activeElement?.id === tableId) { - document.getElementById(rowElementId)?.focus() - } - return Message.CompletedFocusRow() - }), -}) - -export const AnnounceTable = Command.define("AnnounceTable", { - args: { message: Schema.String, timeout: Schema.Number }, - messages: [Message.CompletedAnnounce], - execute: ({ message, timeout }) => - Effect.sync(() => announce(message, timeout)).pipe(Effect.as(Message.CompletedAnnounce())), -}) - -const SELECTION_TIMEOUT = 7000 -/** useTable announces a sort change for 500 ms. */ -const SORT_TIMEOUT = 500 - -const rowTextOf = (message: Message) => - message._tag === "PressedRow" || message._tag === "NavigatedToRow" || message._tag === "PressedRowSpace" - ? Option.some({ row: message.row, text: message.rowText }) - : Option.none() - -const sortDescription = (model: Model) => - Option.map(model.maybeSort, (sort) => `sorted by column in ${sort.direction} order`) - -/** - * useTable's live announcements: useGridSelectionAnnouncement on a selection change and the sort - * description. Legacy's columns render through a function, so the column name is always empty. - */ -const announcementsFor = (previous: Model, next: Model, message: Message) => { - const selection = - previous.selectedKeys === next.selectedKeys - ? "" - : gridSelectionMessage({ - previous: previous.selectedKeys, - next: - message._tag === "ToggledAll" && next.selectedKeys.length > 0 - ? "all" - : next.selectedKeys, - isReplace: next.selectionMode === "single", - isMultiple: next.selectionMode === "multiple", - rowText: (key) => - Option.match(rowTextOf(message), { - onNone: () => "", - onSome: ({ row, text }) => (row === key ? text : ""), - }), - }) - // useUpdateEffect on the description string: with no column name, only a direction change shows. - const sort = Option.filter( - sortDescription(next), - (description) => !Option.contains(sortDescription(previous), description), - ) - return [ - ...(selection === "" ? [] : [AnnounceTable({ message: selection, timeout: SELECTION_TIMEOUT })]), - ...Option.toArray(Option.map(sort, (message) => AnnounceTable({ message, timeout: SORT_TIMEOUT }))), - ] -} - -// UPDATE - -const withFocus = (model: Model, focus: Focus) => modifyFields(model, { focus: () => focus }) -const withModality = (model: Model, modality: Collection.Modality) => - modifyFields(model, { modality: () => modality }) - -/** Replace behavior selects the row; toggle behavior (multiple) flips it. */ -const selectRow = (model: Model, row: string, isSelectable: boolean) => - !isSelectable - ? model - : model.selectionMode === "single" - ? modifyFields(model, { selectedKeys: () => [row] }) - : model.selectionMode === "multiple" - ? modifyFields(model, { - selectedKeys: (keys) => - keys.includes(row) ? keys.filter((key) => key !== row) : [...keys, row], - }) - : model - -const nextSort = (model: Model, column: string): SortDescriptor => - Option.match(model.maybeSort, { - onNone: () => ({ column, direction: "ascending" }), - onSome: (sort) => - sort.column === column - ? { column, direction: sort.direction === "ascending" ? "descending" : "ascending" } - : { column, direction: "ascending" }, - }) - -const without = (maybeKey: Option.Option<string>, key: string) => - Option.filter(maybeKey, (other) => other !== key) - -export const update = (model: Model, message: Message): Update.Return<Model, Message> => { - const result = updateTable(model, message) - const announcements = announcementsFor(model, result.model, message) - return announcements.length === 0 - ? result - : { ...result, commands: [...(result.commands ?? []), ...announcements] } -} - -const updateTable = (model: Model, message: Message) => - Message.match<Update.Return<Model, Message>>(message, { - HoveredRow: ({ row }) => ({ - model: modifyFields(model, { maybeHoveredRow: () => Option.some(row) }), - }), - UnhoveredRow: ({ row }) => ({ - model: modifyFields(model, { maybeHoveredRow: (maybe) => without(maybe, row) }), - }), - HoveredColumn: ({ column }) => ({ - model: modifyFields(model, { maybeHoveredColumn: () => Option.some(column) }), - }), - UnhoveredColumn: ({ column }) => ({ - model: modifyFields(model, { maybeHoveredColumn: (maybe) => without(maybe, column) }), - }), - PressedRow: ({ row, isSelectable }) => ({ - model: selectRow(withModality(model, "Pointer"), row, isSelectable), - }), - ClickedColumn: ({ column }) => ({ - model: modifyFields(withModality(model, "Pointer"), { - maybeSort: () => Option.some(nextSort(model, column)), - }), - }), - FocusedTable: ({ targetRow }) => ({ - model, - commands: [FocusRowFromTable({ tableId: model.id, rowElementId: rowId(model, targetRow) })], - }), - FocusedRow: ({ row }) => ({ model: withFocus(model, { _tag: "Row", row }) }), - FocusedCell: ({ row, column }) => ({ model: withFocus(model, { _tag: "Cell", row, column }) }), - FocusedColumn: ({ column }) => ({ model: withFocus(model, { _tag: "Column", column }) }), - BlurredTable: () => ({ model: withFocus(model, { _tag: "Outside" }) }), - // Toggle behavior moves focus without selecting; Space toggles instead. - NavigatedToRow: ({ row, isSelectable }) => ({ - model: - model.selectionMode === "multiple" - ? withModality(model, "Keyboard") - : selectRow(withModality(model, "Keyboard"), row, isSelectable), - }), - PressedRowSpace: ({ row }) => ({ model: selectRow(withModality(model, "Keyboard"), row, true) }), - // The header checkbox: select every enabled row, or clear them all once they are. - ToggledAll: ({ keys }) => ({ - model: modifyFields(model, { - selectedKeys: (selected) => (keys.every((key) => selected.includes(key)) ? [] : [...keys]), - }), - }), - ReleasedKey: () => ({ model: withModality(model, "Keyboard") }), - GotInteractionMessage: ({ message: child }) => ({ - model: modifyFields(model, { - interaction: () => Interaction.update(model.interaction, child).model, - }), - }), - CompletedFocusRow: () => ({ model }), - CompletedAnnounce: () => ({ model }), - }) - -// SUBSCRIPTION - -/** Modality tracking for the selection checkboxes (lift it where checkboxes are shown). */ -export const subscriptions = Subscription.lift(Interaction.subscriptions)<Model, Message>({ - read: (model) => Option.some(model.interaction), - toParentMessage: (message) => Message.GotInteractionMessage({ message }), -}) - -/** The utility column holding "Select All". */ -export const SELECTION_COLUMN = "__selection" diff --git a/apps/web-foldkit/src/ui/text.ts b/apps/web-foldkit/src/ui/text.ts deleted file mode 100644 index 9d2889bd3..000000000 --- a/apps/web-foldkit/src/ui/text.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { keyboardStyles } from "~/components/ui/keyboard.styles" -import { textStyles } from "~/components/ui/text.styles" - -/** Ports of `components/ui/text.tsx` (Text, Strong, Code) and `keyboard.tsx`. */ -type Children = ReadonlyArray<Html | string> -interface Options { - readonly className?: string -} - -export const text = <Message>(h: HtmlBuilder<Message>, options: Options, children: Children): Html => - h.p( - [h.Attribute("data-slot", "text"), h.Class(twMerge(textStyles.text, options.className))], - [...children], - ) - -export const strong = <Message>(h: HtmlBuilder<Message>, options: Options, children: Children): Html => - h.strong([h.Class(twMerge(textStyles.strong, options.className))], [...children]) - -export const code = <Message>(h: HtmlBuilder<Message>, options: Options, children: Children): Html => - h.code([h.Class(twMerge(textStyles.code, options.className))], [...children]) - -export const keyboard = <Message>(h: HtmlBuilder<Message>, options: Options, children: Children): Html => - h.kbd( - [ - h.Attribute("dir", "ltr"), - h.Attribute("data-slot", "keyboard"), - h.Class(twMerge(keyboardStyles, options.className)), - ], - [...children], - ) diff --git a/apps/web-foldkit/src/ui/toggle-group.scene.test.ts b/apps/web-foldkit/src/ui/toggle-group.scene.test.ts deleted file mode 100644 index 36d105a15..000000000 --- a/apps/web-foldkit/src/ui/toggle-group.scene.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// @vitest-environment jsdom -import * as Scene from "foldkit/scene" -import { describe, test } from "vitest" -import { installCssEscape } from "../test/kit-collections-fixtures" -import { init, update, view } from "./toggle-group" - -installCssEscape() - -/** ToggleGroup through the view: radiogroup or toolbar roles, checked/pressed state, arrow keys. */ - -const sceneView = Scene.withViewInputs(view, { - items: [ - { key: "left", content: ["Left"] }, - { key: "center", content: ["Center"] }, - { key: "right", content: ["Right"] }, - { key: "justify", content: ["Justify"], isDisabled: true }, - ], -}) -const config = { update, view: sceneView() } -const radio = (name: string) => Scene.role("radio", { name }) -const button = (name: string) => Scene.role("button", { name }) -const alignment = init({ label: "Alignment", selectedKeys: ["center"] }) - -describe("toggle-group scene", () => { - test("a single group is a labelled horizontal radiogroup of radios", () => { - Scene.scene( - config, - Scene.given(alignment), - Scene.expect(Scene.role("radiogroup", { name: "Alignment" })).toHaveAttr("aria-orientation", "horizontal"), - Scene.expectAll(Scene.all.role("radio")).toHaveCount(4), - Scene.expect(radio("Center")).toHaveAttr("aria-checked", "true"), - Scene.expect(radio("Left")).toHaveAttr("aria-checked", "false"), - Scene.expect(radio("Justify")).toBeDisabled(), - ) - }) - - test("clicking a radio checks it and unchecks the previous one", () => { - Scene.scene( - config, - Scene.given(alignment), - Scene.click(radio("Left")), - Scene.expect(radio("Left")).toHaveAttr("aria-checked", "true"), - Scene.expect(radio("Left")).toHaveAttr("data-selected", "true"), - Scene.expect(radio("Center")).toHaveAttr("aria-checked", "false"), - ) - }) - - test("a multiple group is a toolbar of pressed buttons", () => { - Scene.scene( - config, - Scene.given(init({ label: "Formatting", selectionMode: "multiple", selectedKeys: ["left"] })), - Scene.expect(Scene.role("toolbar", { name: "Formatting" })).toExist(), - Scene.expect(button("Left")).toHaveAttr("aria-pressed", "true"), - Scene.click(button("Right")), - Scene.expect(button("Right")).toHaveAttr("aria-pressed", "true"), - Scene.expect(button("Left")).toHaveAttr("aria-pressed", "true"), - ) - }) - - test("ArrowRight moves to the next item without changing the selection", () => { - Scene.scene( - config, - Scene.given(alignment), - Scene.keydown(radio("Center"), "ArrowRight"), - Scene.expectHandled(), - Scene.expect(radio("Center")).toHaveAttr("aria-checked", "true"), - Scene.expect(radio("Right")).toHaveAttr("aria-checked", "false"), - ) - }) - - test("ArrowRight before the disabled last item goes nowhere since the group does not wrap", () => { - Scene.scene(config, Scene.given(alignment), Scene.keydown(radio("Right"), "ArrowRight"), Scene.expectIgnored()) - }) - - test("ArrowDown is ignored because the group is always horizontal", () => { - Scene.scene(config, Scene.given(alignment), Scene.keydown(radio("Left"), "ArrowDown"), Scene.expectIgnored()) - }) - - test("hovering and focusing an item mark it hovered and focus-visible", () => { - Scene.scene( - config, - Scene.given(alignment), - Scene.hover(radio("Left")), - Scene.expect(radio("Left")).toHaveAttr("data-hovered", "true"), - Scene.focus(radio("Right")), - Scene.expect(radio("Right")).toHaveAttr("data-focus-visible", "true"), - Scene.expect(radio("Justify")).not.toHaveHandler("click"), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/toggle-group.story.test.ts b/apps/web-foldkit/src/ui/toggle-group.story.test.ts deleted file mode 100644 index 2847847bf..000000000 --- a/apps/web-foldkit/src/ui/toggle-group.story.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Option } from "effect" -import { Command, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { init, Message, update } from "./toggle-group" - -/** React Aria ToggleButtonGroup: single and multiple selection plus hover, focus and modality. */ - -const alignment = init({ label: "Alignment", selectedKeys: ["center"] }) -const formatting = init({ label: "Formatting", selectionMode: "multiple", selectedKeys: ["bold"] }) -const click = (key: string) => message(Message.ClickedItem({ key })) - -describe("toggle-group story", () => { - test("clicking an item in a single group replaces the selection", () => { - story( - update, - given(alignment), - click("left"), - Command.expectNone(), - model((next) => expect(next.selectedKeys).toEqual(["left"])), - ) - }) - - test("clicking the selected item in a single group clears the selection", () => { - story( - update, - given(alignment), - click("center"), - model((next) => expect(next.selectedKeys).toEqual([])), - ) - }) - - test("items in a multiple group toggle independently", () => { - story( - update, - given(formatting), - click("italic"), - model((next) => expect(next.selectedKeys).toEqual(["bold", "italic"])), - click("bold"), - model((next) => expect(next.selectedKeys).toEqual(["italic"])), - ) - }) - - test("pointer presses switch to pointer modality and arrow keys back to keyboard", () => { - story( - update, - given(alignment), - message(Message.PressedPointer()), - model((next) => expect(next.interaction.modality).toBe("Pointer")), - message(Message.PressedNavigationKey()), - model((next) => expect(next.interaction.modality).toBe("Keyboard")), - message(Message.PressedPointer()), - message(Message.ReleasedKey()), - model((next) => expect(next.interaction.modality).toBe("Keyboard")), - ) - }) - - test("unhovering a different item keeps the current hover", () => { - story( - update, - given(alignment), - message(Message.HoveredItem({ key: "right" })), - message(Message.UnhoveredItem({ key: "left" })), - model((next) => expect(next.interaction.maybeHoveredKey).toEqual(Option.some("right"))), - message(Message.UnhoveredItem({ key: "right" })), - model((next) => expect(next.interaction.maybeHoveredKey).toEqual(Option.none())), - ) - }) - - test("focus follows the focused item and clears on its blur", () => { - story( - update, - given(alignment), - message(Message.FocusedItem({ key: "left" })), - model((next) => expect(next.interaction.maybeFocusedKey).toEqual(Option.some("left"))), - message(Message.BlurredItem({ key: "left" })), - model((next) => expect(next.interaction.maybeFocusedKey).toEqual(Option.none())), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/toggle-group.ts b/apps/web-foldkit/src/ui/toggle-group.ts deleted file mode 100644 index b6a289442..000000000 --- a/apps/web-foldkit/src/ui/toggle-group.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Array, Option, Schema } from "effect" -import type { Update } from "foldkit" -import type { Attribute, Html } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { defineView } from "foldkit/submodel" -import { twMerge } from "tailwind-merge" -import { toggleGroupItemStyles, toggleGroupStyles } from "~/components/ui/toggle-group.styles" -import * as Collection from "./aria/collection" - -/** - * Port of `components/ui/toggle-group.tsx` (React Aria ToggleButtonGroup). Like legacy, the - * `orientation` prop only styles the group: RA always gets the default horizontal orientation. - */ - -// MODEL - -export const SelectionMode = Schema.Literals(["single", "multiple"]) -export type SelectionMode = typeof SelectionMode.Type - -export const Model = Schema.Struct({ - label: Schema.String, - selectionMode: SelectionMode, - selectedKeys: Schema.Array(Schema.String), - interaction: Collection.Interaction, -}) -export type Model = typeof Model.Type - -export const init = (config: { - readonly label: string - readonly selectionMode?: SelectionMode - readonly selectedKeys?: ReadonlyArray<string> -}): Model => ({ - label: config.label, - selectionMode: config.selectionMode ?? "single", - selectedKeys: config.selectedKeys ?? [], - interaction: Collection.initInteraction(), -}) - -// MESSAGE - -export const Message = defineMessageUnion({ - HoveredItem: { key: Schema.String }, - UnhoveredItem: { key: Schema.String }, - PressedPointer: {}, - ReleasedKey: {}, - ClickedItem: { key: Schema.String }, - FocusedItem: { key: Schema.String }, - BlurredItem: { key: Schema.String }, - PressedNavigationKey: {}, -}) -export type Message = typeof Message.Type - -// UPDATE - -const withInteraction = ( - model: Model, - next: (interaction: Collection.Interaction) => Collection.Interaction, -) => modifyFields(model, { interaction: next }) - -const toggleKey = (model: Model, key: string): ReadonlyArray<string> => { - const isSelected = model.selectedKeys.includes(key) - if (model.selectionMode === "single") { - return isSelected ? [] : [key] - } else { - return isSelected - ? Array.filter(model.selectedKeys, (selected) => selected !== key) - : [...model.selectedKeys, key] - } -} - -export const update = (model: Model, message: Message) => - Message.match<Update.Return<Model, Message>>(message, { - HoveredItem: ({ key }) => ({ model: withInteraction(model, (it) => Collection.hoverItem(it, key)) }), - UnhoveredItem: ({ key }) => ({ - model: withInteraction(model, (it) => Collection.unhoverItem(it, key)), - }), - PressedPointer: () => ({ - model: withInteraction(model, (it) => Collection.useModality(it, "Pointer")), - }), - ReleasedKey: () => ({ - model: withInteraction(model, (it) => Collection.useModality(it, "Keyboard")), - }), - ClickedItem: ({ key }) => ({ - model: modifyFields(model, { selectedKeys: () => toggleKey(model, key) }), - }), - FocusedItem: ({ key }) => ({ model: withInteraction(model, (it) => Collection.focusItem(it, key)) }), - BlurredItem: ({ key }) => ({ model: withInteraction(model, (it) => Collection.blurItem(it, key)) }), - PressedNavigationKey: () => ({ - model: withInteraction(model, (it) => Collection.useModality(it, "Keyboard")), - }), - }) - -// VIEW - -export type ToggleSize = "xs" | "sm" | "md" | "lg" | "sq-xs" | "sq-sm" | "sq-md" | "sq-lg" - -export interface ToggleGroupItem { - readonly key: string - readonly content: ReadonlyArray<Html | string> - readonly isDisabled?: boolean - readonly ariaLabel?: string - readonly className?: string -} - -export type ViewInputs = Readonly<{ - items: ReadonlyArray<ToggleGroupItem> - size?: ToggleSize - orientation?: Collection.Orientation - isCircle?: boolean - className?: string -}> - -export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const { selectionMode, interaction } = model - const size = viewInputs.size ?? "md" - const orientation = viewInputs.orientation ?? "horizontal" - const keys = Array.map(viewInputs.items, (item) => item.key) - const isDisabledKey = (key: string) => - Array.some(viewInputs.items, (item) => item.key === key && item.isDisabled === true) - // NOTE: focus moves synchronously in keydown, as in RA, so a following key reaches the new item. - const navigate = (fromKey: string) => (keyboardKey: string) => - Option.flatMap(Collection.directionOfKey("horizontal", keyboardKey), (direction) => - Option.map(Collection.moveKey(keys, fromKey, direction, isDisabledKey, false), (key) => ({ - focusSelector: Collection.labelledChildSelector( - model.label, - Option.getOrElse( - Array.findFirstIndex(keys, (candidate) => candidate === key), - () => 0, - ), - ), - message: Message.PressedNavigationKey(), - })), - ) - - const itemView = (item: ToggleGroupItem): Html => { - const isDisabled = item.isDisabled === true - const isSelected = model.selectedKeys.includes(item.key) - const state = Collection.itemState(interaction, item.key, isDisabled) - const handlers: ReadonlyArray<Attribute<Message>> = isDisabled - ? [h.Disabled(true)] - : [ - h.Tabindex(0), - h.OnMouseEnter(Message.HoveredItem({ key: item.key })), - h.OnMouseLeave(Message.UnhoveredItem({ key: item.key })), - h.OnPointerDown(() => Option.some(Message.PressedPointer())), - h.OnKeyUp(() => Message.ReleasedKey()), - h.OnKeyDownFocus(navigate(item.key)), - h.OnClick(Message.ClickedItem({ key: item.key })), - h.OnFocus(Message.FocusedItem({ key: item.key })), - h.OnBlur(Message.BlurredItem({ key: item.key })), - ] - return h.keyed("button")( - item.key, - [ - h.Class( - twMerge( - toggleGroupItemStyles({ - ...state, - isSelected, - isDisabled, - size, - orientation, - selectionMode, - className: item.className, - }), - ), - ), - h.Type("button"), - ...(selectionMode === "single" - ? [h.Role("radio"), h.AriaChecked(isSelected)] - : [h.AriaPressed(isSelected ? "true" : "false")]), - ...(item.ariaLabel === undefined ? [] : [h.AriaLabel(item.ariaLabel)]), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("data-slot", "toggle-group-item"), - ...Collection.stateAttributes(h, { ...state, isSelected, isDisabled }), - ...handlers, - ], - [...item.content], - ) - } - - return h.div( - [ - h.Class( - twMerge( - twMerge(toggleGroupStyles({ orientation, selectionMode, isCircle: viewInputs.isCircle })), - viewInputs.className, - ), - ), - h.Role(selectionMode === "single" ? "radiogroup" : "toolbar"), - h.AriaLabel(model.label), - h.AriaOrientation("horizontal"), - h.Attribute("data-orientation", "horizontal"), - h.Attribute("data-rac", ""), - h.Attribute("data-slot", "control"), - ], - Array.map(viewInputs.items, itemView), - ) -}) diff --git a/apps/web-foldkit/src/ui/toggle.scene.test.ts b/apps/web-foldkit/src/ui/toggle.scene.test.ts deleted file mode 100644 index a0d31ae21..000000000 --- a/apps/web-foldkit/src/ui/toggle.scene.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import * as Scene from "foldkit/scene" -import type { HtmlBuilder } from "foldkit/html" -import { describe, test } from "vitest" -import { init, Message, type Model, page, update, wiring } from "../test/kit-forms-fixtures" -import { toggle } from "./toggle" - -/** ToggleButton: aria-pressed follows the parent's selection, pressing flips it. */ - -const view = - (isDisabled = false) => - (model: Model, h: HtmlBuilder<Message>) => - page(h, [ - toggle( - h, - { - isSelected: model.isSelected, - onPress: Message.ChangedSelection({ isSelected: !model.isSelected }), - interaction: { wiring: wiring(model), target: "bold" }, - isDisabled, - }, - ["Bold"], - ), - ]) - -const bold = Scene.role("button", { name: "Bold" }) - -describe("toggle scene", () => { - test("an unselected toggle is a button with aria-pressed false", () => { - Scene.scene( - { update, view: view() }, - Scene.given(init()), - Scene.expect(bold).toHaveAttr("type", "button"), - Scene.expect(bold).toHaveAttr("aria-pressed", "false"), - Scene.expect(bold).not.toHaveAttr("data-selected"), - ) - }) - - test("clicking the toggle selects it and clicking again deselects it", () => { - Scene.scene( - { update, view: view() }, - Scene.given(init()), - Scene.click(bold), - Scene.expect(bold).toHaveAttr("aria-pressed", "true"), - Scene.expect(bold).toHaveAttr("data-selected", "true"), - Scene.click(bold), - Scene.expect(bold).toHaveAttr("aria-pressed", "false"), - ) - }) - - test("holding Space shows the pressed state before the click lands", () => { - Scene.scene( - { update, view: view() }, - Scene.given(init()), - Scene.keydown(bold, " "), - Scene.expect(bold).toHaveAttr("data-pressed", "true"), - Scene.expect(bold).toHaveAttr("aria-pressed", "false"), - ) - }) - - test("hover and keyboard focus show data-hovered and data-focus-visible", () => { - Scene.scene( - { update, view: view() }, - Scene.given(init()), - Scene.hover(bold), - Scene.focus(bold), - Scene.expect(bold).toHaveAttr("data-hovered", "true"), - Scene.expect(bold).toHaveAttr("data-focus-visible", "true"), - ) - }) - - test("a disabled toggle is disabled and shows no interaction state even when hovered in the Model", () => { - const hovered = init({ - interaction: { ...init().interaction, hovered: ["bold"] }, - }) - Scene.scene( - { update, view: view(true) }, - Scene.given(hovered), - Scene.expect(bold).toBeDisabled(), - Scene.expect(bold).toHaveAttr("data-disabled", "true"), - Scene.expect(bold).not.toHaveAttr("data-hovered"), - Scene.expect(bold).not.toHaveHandler("click"), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/toggle.ts b/apps/web-foldkit/src/ui/toggle.ts deleted file mode 100644 index 1d963d138..000000000 --- a/apps/web-foldkit/src/ui/toggle.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import type { VariantProps } from "tailwind-variants" -import { toggleStyles } from "~/components/ui/toggle.styles" -import * as Interaction from "./aria/interaction" - -/** - * Port of `components/ui/toggle.tsx` (React Aria ToggleButton). Selection lives in the parent's - * Model; hover, press and focus-visible come from the page's interaction Submodel. - */ - -export type ToggleVariants = Omit<VariantProps<typeof toggleStyles>, "isDisabled"> - -export interface ToggleOptions<Message> extends ToggleVariants { - readonly isSelected: boolean - readonly onPress: Message - readonly interaction: { readonly wiring: Interaction.Wiring<Message>; readonly target: string } - readonly isDisabled?: boolean - readonly className?: string - readonly ariaLabel?: string -} - -const flag = <Message>(h: HtmlBuilder<Message>, name: string, isOn: boolean) => - isOn ? [h.DataAttribute(name, "true")] : [] - -export const toggle = <Message>( - h: HtmlBuilder<Message>, - options: ToggleOptions<Message>, - content: ReadonlyArray<Html | string>, -): Html => { - const isDisabled = options.isDisabled === true - const { wiring, target } = options.interaction - const state = isDisabled ? Interaction.idleState : Interaction.stateOf(wiring.model, target) - // React Aria's render props, spread whole into tv() as the legacy component does. - const renderProps = { ...state, isSelected: options.isSelected, isDisabled } - const handlers: ReadonlyArray<Attribute<Message>> = isDisabled - ? [h.Disabled(true)] - : [ - h.Tabindex(0), - ...Interaction.handlers(h, wiring, target), - ...Interaction.pressStyleAttributes(h, wiring.model, target), - h.OnClick(options.onPress), - ] - return h.button( - [ - h.Class( - twMerge( - toggleStyles({ - ...renderProps, - isCircle: options.isCircle, - size: options.size, - intent: options.intent, - className: options.className, - }), - ), - ), - h.Type("button"), - h.AriaPressed(options.isSelected ? "true" : "false"), - ...(options.ariaLabel === undefined ? [] : [h.AriaLabel(options.ariaLabel)]), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - ...Interaction.stateAttributes(h, state), - ...flag(h, "selected", options.isSelected), - ...flag(h, "disabled", isDisabled), - ...handlers, - ], - [...content], - ) -} diff --git a/apps/web-foldkit/src/ui/tree-view.ts b/apps/web-foldkit/src/ui/tree-view.ts deleted file mode 100644 index 6d340a4f9..000000000 --- a/apps/web-foldkit/src/ui/tree-view.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Array, Option } from "effect" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" -import { defineView } from "foldkit/submodel" -import { twJoin, twMerge } from "tailwind-merge" -import { treeStyles } from "~/components/ui/tree.styles" -import * as Collection from "./aria/collection" -import { Message, type Model, rowId } from "./tree" - -export interface TreeNode { - readonly key: string - readonly textValue: string - readonly content: ReadonlyArray<Html | string> - readonly isDisabled?: boolean - readonly children?: ReadonlyArray<TreeNode> -} - -export type ViewInputs = Readonly<{ - label: string - nodes: ReadonlyArray<TreeNode> - className?: string -}> - -interface VisibleRow { - readonly node: TreeNode - readonly level: number - readonly position: number - readonly setSize: number -} - -/** `@heroicons/react/20/solid` ChevronRightIcon, as the legacy TreeIndicator renders it. */ -const chevronIcon = <Message>(h: HtmlBuilder<Message>, isExpanded: boolean): Html => - h.svg( - [ - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Attribute("viewBox", "0 0 20 20"), - h.Attribute("fill", "currentColor"), - h.Attribute("aria-hidden", "true"), - h.Attribute("data-slot", "chevron"), - h.Class(twJoin(...treeStyles.chevron(isExpanded))), - ], - [ - h.path([ - h.Attribute("fill-rule", "evenodd"), - h.Attribute( - "d", - "M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z", - ), - h.Attribute("clip-rule", "evenodd"), - ]), - ], - ) - -export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const isExpanded = (key: string) => model.expandedKeys.includes(key) - const isVisible = model.modality !== "Pointer" - - const flatten = (nodes: ReadonlyArray<TreeNode>, level: number): ReadonlyArray<VisibleRow> => - nodes.flatMap((node, index) => [ - { node, level, position: index + 1, setSize: nodes.length }, - ...(node.children && isExpanded(node.key) ? flatten(node.children, level + 1) : []), - ]) - const rows = flatten(viewInputs.nodes, 1) - const keys = Array.map(rows, (row) => row.node.key) - const isDisabledKey = (key: string) => - Array.some(rows, (row) => row.node.key === key && row.node.isDisabled === true) - const firstEnabled = Array.findFirst(rows, (row) => row.node.isDisabled !== true) - - const navigation = (row: VisibleRow) => (keyboardKey: string) => { - const hasChildren = row.node.children !== undefined - const selfSelector = Collection.idSelector(rowId(model, row.node.key)) - if (keyboardKey === "ArrowRight" && hasChildren && !isExpanded(row.node.key)) { - return Option.some({ - focusSelector: selfSelector, - message: Message.PressedExpandKey({ key: row.node.key }), - }) - } else if (keyboardKey === "ArrowLeft" && hasChildren && isExpanded(row.node.key)) { - return Option.some({ - focusSelector: selfSelector, - message: Message.PressedCollapseKey({ key: row.node.key }), - }) - } else { - return Option.map( - Option.flatMap(Collection.directionOfKey("vertical", keyboardKey), (direction) => - Collection.moveKey(keys, row.node.key, direction, isDisabledKey, false), - ), - (key) => ({ - focusSelector: Collection.idSelector(rowId(model, key)), - message: Message.NavigatedToRow(), - }), - ) - } - } - - const rowView = (row: VisibleRow): Html => { - const { node, level } = row - const hasChildren = node.children !== undefined - const isRowExpanded = hasChildren && isExpanded(node.key) - const isDisabled = node.isDisabled === true - const isFocused = Option.contains(model.maybeFocusedKey, node.key) - const chevronId = `${rowId(model, node.key)}-chevron` - const interactive: ReadonlyArray<Attribute<Message>> = isDisabled - ? [h.AriaDisabled(true)] - : [ - h.Tabindex(isFocused ? 0 : -1), - h.OnFocus(Message.FocusedRow({ key: node.key })), - h.OnBlur(Message.BlurredRow({ key: node.key })), - h.OnPointerDown(() => Option.some(Message.PressedRow())), - h.OnKeyUp(() => Message.ReleasedKey()), - h.OnKeyDownFocus(navigation(row)), - ] - return h.keyed("div")( - node.key, - [ - ...(hasChildren ? [h.AriaExpanded(isRowExpanded)] : []), - h.AriaLabel(node.textValue), - h.Attribute("aria-level", String(level)), - h.Attribute("aria-posinset", String(row.position)), - h.Attribute("aria-setsize", String(row.setSize)), - h.Class(twMerge(twMerge(treeStyles.item(false)))), - h.Attribute("data-collection", model.id), - ...(isRowExpanded ? [h.Attribute("data-expanded", "true")] : []), - ...(hasChildren ? [h.Attribute("data-has-child-items", "true")] : []), - h.Attribute("data-key", node.key), - h.Attribute("data-level", String(level)), - h.Attribute("data-rac", ""), - ...(hasChildren && !isDisabled ? [h.Attribute("data-react-aria-pressable", "true")] : []), - ...Collection.stateAttributes(h, { - isFocused, - isFocusVisible: isFocused && isVisible, - isDisabled, - }), - h.Id(rowId(model, node.key)), - h.Role("row"), - h.Attribute("style", `--tree-item-level: ${level};`), - ...interactive, - ], - [ - h.div( - [ - h.Attribute("aria-colindex", "1"), - h.Role("gridcell"), - h.Attribute("style", "display: contents;"), - ], - [ - h.div( - [h.Class(twMerge(treeStyles.content))], - [ - h.div([h.Class(twJoin(...treeStyles.levelGuide))]), - hasChildren - ? h.button( - [ - h.AriaLabel(isRowExpanded ? "Collapse" : "Expand"), - h.AriaLabelledBy(`${chevronId} ${rowId(model, node.key)}`), - h.Class(twJoin(...treeStyles.indicator(isRowExpanded))), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("data-react-aria-prevent-focus", "true"), - h.Id(chevronId), - h.Attribute("slot", "chevron"), - h.Tabindex(-1), - h.Type("button"), - ...(isDisabled - ? [h.Disabled(true)] - : [h.OnClick(Message.ClickedChevron({ key: node.key }))]), - ], - [chevronIcon(h, isRowExpanded)], - ) - : h.span([ - h.Attribute("aria-hidden", "true"), - h.Class(treeStyles.leafSpacer), - ]), - ...node.content, - ], - ), - ], - ), - ], - ) - } - - const focusScope = (edge: "start" | "end") => - h.span([h.Attribute(`data-focus-scope-${edge}`, "true"), h.Attribute("hidden", "")]) - - return h.div( - [h.Class("contents")], - [ - focusScope("start"), - h.div( - [ - h.AriaLabel(viewInputs.label), - h.Class(twMerge(twMerge(twJoin(...treeStyles.tree)), viewInputs.className)), - h.Attribute("data-collection", model.id), - h.Attribute("data-rac", ""), - h.Id(model.id), - h.Role("treegrid"), - h.Tabindex(Option.isSome(model.maybeFocusedKey) ? -1 : 0), - ...Option.match(firstEnabled, { - onNone: () => [], - onSome: (row) => [ - h.OnFocus(Message.FocusedTree({ targetKey: row.node.key })), - // NOTE: live focus check; rows handle their own keys before this bubbles here. - h.OnKeyDownFocus((keyboardKey) => - document.activeElement?.id === model.id - ? navigation(row)(keyboardKey) - : Option.none(), - ), - ], - }), - ], - Array.map(rows, rowView), - ), - focusScope("end"), - ], - ) -}) diff --git a/apps/web-foldkit/src/ui/tree.scene.test.ts b/apps/web-foldkit/src/ui/tree.scene.test.ts deleted file mode 100644 index 77d7bb288..000000000 --- a/apps/web-foldkit/src/ui/tree.scene.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -// @vitest-environment jsdom -import * as Scene from "foldkit/scene" -import { describe, test } from "vitest" -import { installCssEscape } from "../test/kit-collections-fixtures" -import { FocusTreeRow, init, Message, update } from "./tree" -import { type TreeNode, view } from "./tree-view" - -installCssEscape() - -/** Tree through the view: treegrid rows with level, expansion and disabled state, keyboard flows. */ - -const leaf = (key: string, isDisabled = false): TreeNode => ({ key, textValue: key, content: [key], isDisabled }) -const sceneView = Scene.withViewInputs(view, { - label: "Channels", - nodes: [ - { - key: "engineering", - textValue: "Engineering", - content: ["Engineering"], - children: [leaf("frontend"), leaf("archived", true), leaf("backend")], - }, - { key: "design", textValue: "Design", content: ["Design"], children: [leaf("research")] }, - leaf("general"), - ], -}) -const config = { update, view: sceneView() } -const row = (name: string) => Scene.role("row", { name }) -const channels = init({ id: "tree-channels", expandedKeys: ["engineering"] }) - -describe("tree scene", () => { - test("renders a labelled treegrid with only the rows of expanded parents", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.expect(Scene.role("treegrid", { name: "Channels" })).toExist(), - Scene.expectAll(Scene.all.role("row")).toHaveCount(6), - Scene.expect(row("Engineering")).toHaveAttr("aria-expanded", "true"), - Scene.expect(row("Design")).toHaveAttr("aria-expanded", "false"), - Scene.expect(row("general")).not.toHaveAttr("aria-expanded"), - Scene.expect(row("research")).toBeAbsent(), - ) - }) - - test("rows carry their level and position within the parent", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.expect(row("Engineering")).toHaveAttr("aria-level", "1"), - Scene.expect(row("backend")).toHaveAttr("aria-level", "2"), - Scene.expect(row("backend")).toHaveAttr("aria-posinset", "3"), - Scene.expect(row("backend")).toHaveAttr("aria-setsize", "3"), - Scene.expect(row("archived")).toHaveAttr("aria-disabled", "true"), - Scene.expect(row("archived")).not.toHaveHandler("keydown"), - ) - }) - - test("clicking a chevron expands the row and reveals its children", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.click(Scene.selector("#tree-channels-design-chevron")), - Scene.Command.expectExact(FocusTreeRow), - Scene.Command.resolve(FocusTreeRow, Message.CompletedFocusRow()), - Scene.expect(row("Design")).toHaveAttr("aria-expanded", "true"), - Scene.expect(row("research")).toHaveAttr("aria-level", "2"), - ) - }) - - test("ArrowRight expands a collapsed row and ArrowLeft collapses it again", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.keydown(row("Design"), "ArrowRight"), - Scene.expect(row("Design")).toHaveAttr("data-expanded", "true"), - Scene.expect(row("research")).toExist(), - Scene.keydown(row("Design"), "ArrowLeft"), - Scene.expect(row("Design")).toHaveAttr("aria-expanded", "false"), - Scene.expect(row("research")).toBeAbsent(), - ) - }) - - test("ArrowDown past the disabled row is handled and ArrowDown on the last row is not", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.keydown(row("frontend"), "ArrowDown"), - Scene.expectHandled(), - Scene.keydown(row("general"), "ArrowDown"), - Scene.expectIgnored(), - ) - }) - - test("ArrowRight on a leaf row does nothing", () => { - Scene.scene(config, Scene.given(channels), Scene.keydown(row("general"), "ArrowRight"), Scene.expectIgnored()) - }) - - test("a keyboard-focused row shows focus-visible, a pointer-focused one does not", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.focus(row("general")), - Scene.expect(row("general")).toHaveAttr("data-focus-visible", "true"), - Scene.pointerDown(row("frontend")), - Scene.focus(row("frontend")), - Scene.expect(row("frontend")).toHaveAttr("data-focused", "true"), - Scene.expect(row("frontend")).not.toHaveAttr("data-focus-visible"), - ) - }) - - test("keys pressed on the tree element itself are ignored unless the browser has focused it", () => { - Scene.scene( - config, - Scene.given(channels), - Scene.keydown(Scene.role("treegrid"), "ArrowDown"), - Scene.expectIgnored(), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/tree.story.test.ts b/apps/web-foldkit/src/ui/tree.story.test.ts deleted file mode 100644 index 2375f0752..000000000 --- a/apps/web-foldkit/src/ui/tree.story.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Option } from "effect" -import { Command, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { FocusTreeRow, init, Message, update } from "./tree" - -/** React Aria Tree: expansion by chevron and arrow keys, roving row focus, input modality. */ - -const channels = init({ id: "tree-channels", expandedKeys: ["engineering"] }) - -describe("tree story", () => { - test("tabbing into the tree hands focus to the first row only while the tree itself is focused", () => { - story( - update, - given(channels), - message(Message.FocusedTree({ targetKey: "engineering" })), - Command.expectExact( - FocusTreeRow({ rowElementId: "tree-channels-engineering", onlyFromId: Option.some("tree-channels") }), - ), - Command.resolve(FocusTreeRow, Message.CompletedFocusRow()), - model((next) => expect(next).toEqual(channels)), - ) - }) - - test("clicking a chevron collapses an expanded row and focuses it", () => { - story( - update, - given(channels), - message(Message.ClickedChevron({ key: "engineering" })), - Command.expectExact(FocusTreeRow({ rowElementId: "tree-channels-engineering", onlyFromId: Option.none() })), - Command.resolve(FocusTreeRow, Message.CompletedFocusRow()), - model((next) => { - expect(next.expandedKeys).toEqual([]) - expect(next.modality).toBe("Pointer") - }), - ) - }) - - test("clicking a collapsed chevron expands the row", () => { - story( - update, - given(channels), - message(Message.ClickedChevron({ key: "design" })), - Command.resolve(FocusTreeRow, Message.CompletedFocusRow()), - model((next) => expect(next.expandedKeys).toEqual(["engineering", "design"])), - ) - }) - - test("ArrowRight expands and ArrowLeft collapses with keyboard modality", () => { - story( - update, - given(channels), - message(Message.PressedExpandKey({ key: "design" })), - Command.expectNone(), - model((next) => { - expect(next.expandedKeys).toEqual(["engineering", "design"]) - expect(next.modality).toBe("Keyboard") - }), - message(Message.PressedCollapseKey({ key: "engineering" })), - model((next) => expect(next.expandedKeys).toEqual(["design"])), - ) - }) - - test("expanding an already expanded row does not duplicate it", () => { - story( - update, - given(channels), - message(Message.PressedExpandKey({ key: "engineering" })), - model((next) => expect(next.expandedKeys).toEqual(["engineering"])), - ) - }) - - test("row focus is tracked until that row blurs", () => { - story( - update, - given(channels), - message(Message.FocusedRow({ key: "frontend" })), - message(Message.BlurredRow({ key: "backend" })), - model((next) => expect(next.maybeFocusedKey).toEqual(Option.some("frontend"))), - message(Message.BlurredRow({ key: "frontend" })), - model((next) => expect(next.maybeFocusedKey).toEqual(Option.none())), - ) - }) - - test("pressing a row switches to pointer modality and arrow navigation back to keyboard", () => { - story( - update, - given(channels), - message(Message.PressedRow()), - model((next) => expect(next.modality).toBe("Pointer")), - message(Message.NavigatedToRow()), - model((next) => expect(next.modality).toBe("Keyboard")), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/tree.ts b/apps/web-foldkit/src/ui/tree.ts deleted file mode 100644 index 3b64df8a2..000000000 --- a/apps/web-foldkit/src/ui/tree.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Effect, Option, Schema } from "effect" -import type { Update } from "foldkit" -import * as Command from "foldkit/command" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import * as Collection from "./aria/collection" - -/** - * Port of `components/ui/tree.tsx` (React Aria Tree, no selection): expansion by chevron and - * ArrowRight/ArrowLeft, roving focus with ArrowUp/ArrowDown/Home/End over visible rows. The - * view lives in `tree-view.ts`. - */ - -// MODEL - -export const Model = Schema.Struct({ - id: Schema.String, - expandedKeys: Schema.Array(Schema.String), - maybeFocusedKey: Schema.Option(Schema.String), - modality: Collection.Modality, -}) -export type Model = typeof Model.Type - -export const init = (config: { - readonly id: string - readonly expandedKeys?: ReadonlyArray<string> -}): Model => ({ - id: config.id, - expandedKeys: config.expandedKeys ?? [], - maybeFocusedKey: Option.none(), - modality: "Unknown", -}) - -// MESSAGE - -export const Message = defineMessageUnion({ - FocusedTree: { targetKey: Schema.String }, - FocusedRow: { key: Schema.String }, - BlurredRow: { key: Schema.String }, - PressedRow: {}, - ClickedChevron: { key: Schema.String }, - NavigatedToRow: {}, - PressedExpandKey: { key: Schema.String }, - PressedCollapseKey: { key: Schema.String }, - ReleasedKey: {}, - CompletedFocusRow: {}, -}) -export type Message = typeof Message.Type - -// COMMAND - -export const rowId = (model: Model, key: string) => `${model.id}-${key}` - -// NOTE: only moves focus when the tree element itself (or the chevron's row) still needs it. -export const FocusTreeRow = Command.define("FocusTreeRow", { - args: { rowElementId: Schema.String, onlyFromId: Schema.Option(Schema.String) }, - messages: [Message.CompletedFocusRow], - execute: ({ rowElementId, onlyFromId }) => - Effect.sync(() => { - const isAllowed = Option.match(onlyFromId, { - onNone: () => true, - onSome: (id) => document.activeElement?.id === id, - }) - if (isAllowed) { - document.getElementById(rowElementId)?.focus() - } - return Message.CompletedFocusRow() - }), -}) - -// UPDATE - -const withModality = (model: Model, modality: Collection.Modality) => - modifyFields(model, { modality: () => modality }) - -const setExpanded = (model: Model, key: string, isExpanded: boolean) => - modifyFields(model, { - expandedKeys: (keys) => - isExpanded - ? [...keys.filter((other) => other !== key), key] - : keys.filter((other) => other !== key), - }) - -export const update = (model: Model, message: Message) => - Message.match<Update.Return<Model, Message>>(message, { - FocusedTree: ({ targetKey }) => ({ - model, - commands: [ - FocusTreeRow({ rowElementId: rowId(model, targetKey), onlyFromId: Option.some(model.id) }), - ], - }), - FocusedRow: ({ key }) => ({ - model: modifyFields(model, { maybeFocusedKey: () => Option.some(key) }), - }), - BlurredRow: ({ key }) => ({ - model: modifyFields(model, { maybeFocusedKey: Option.filter((focused) => focused !== key) }), - }), - PressedRow: () => ({ model: withModality(model, "Pointer") }), - ClickedChevron: ({ key }) => ({ - model: setExpanded(withModality(model, "Pointer"), key, !model.expandedKeys.includes(key)), - commands: [FocusTreeRow({ rowElementId: rowId(model, key), onlyFromId: Option.none() })], - }), - NavigatedToRow: () => ({ model: withModality(model, "Keyboard") }), - PressedExpandKey: ({ key }) => ({ model: setExpanded(withModality(model, "Keyboard"), key, true) }), - PressedCollapseKey: ({ key }) => ({ - model: setExpanded(withModality(model, "Keyboard"), key, false), - }), - ReleasedKey: () => ({ model: withModality(model, "Keyboard") }), - CompletedFocusRow: () => ({ model }), - }) From 3485ffe49e8ebd519594ec379e5989d1a47b44fd Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:50:54 +0200 Subject: [PATCH 04/52] refactor(web): typed error reasons and Effect optimistic actions, drop effect-atom Define the frontend error convention (docs/foldkit-decisions/s6-errors.md): Failed*({ reason }) built with Effect.catchTags, then catchCommon, the one defect boundary, which collapses the remaining CommonError to a CommonReason. - error-messages.ts: rewritten for Effect v4. An exhaustive Match over the CommonError union reads HttpClientError.reason._tag and RpcClientError's transport reason, so network drops are recognised again. Retry policy (isRetryableReason) is separate from copy, and copy never contains error or defect text. Message sends now retry on transport failures. - data/actions.ts: catchCommon, errorToastOf, reasonToast. failureToast's handlers are typed by the failure's own tags (ErrorHandlers<E>). - optimisticAction returns (variables) => Effect and keeps typed failures through TanStack's Promise. runAtomFn, both AtomRegistries, the AtomRpc and AtomHttpApi clients (one shared the HazelApiClient key), the ManagedRuntime, ApiClient and the empty telemetry layer are gone; callers use src/rpc.ts. - Delete core/atoms: quiet hours and muting move to notification-sound.ts as pure functions of nowMs; emoji usage tracking (write-only) is removed. - Delete the unread notification diagnostics store and the no-op in-app sink. - Tauri key-value store loads lazily and fails per call instead of dying. - XId.make instead of crypto.randomUUID() as XId; drop onRetryAttempt and the txid debug log; one electricCollection factory for the 24 shapes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../web-foldkit/src/app/notification-sinks.ts | 21 +- apps/web-foldkit/src/chat/message/toolbar.ts | 2 +- apps/web-foldkit/src/composer/draft-update.ts | 10 +- apps/web-foldkit/src/composer/draft.ts | 70 +- apps/web-foldkit/src/composer/upload.ts | 33 +- .../web-foldkit/src/core/atoms/emoji-atoms.ts | 53 -- .../atoms/notification-sound-atoms.test.ts | 26 - .../core/atoms/notification-sound-atoms.ts | 100 --- apps/web-foldkit/src/core/db/actions.ts | 160 ++-- apps/web-foldkit/src/core/db/collections.ts | 541 ++------------ .../src/core/lib/error-messages.ts | 694 +++++------------- .../src/core/lib/notifications/README.md | 4 +- .../lib/notifications/diagnostics-store.ts | 33 - .../src/core/lib/notifications/index.ts | 6 - .../lib/notifications/orchestrator.test.ts | 35 +- .../core/lib/notifications/orchestrator.ts | 24 +- .../core/lib/notifications/sinks/in-app.ts | 25 - .../src/core/lib/notifications/types.ts | 2 +- .../src/core/lib/platform-storage/index.ts | 3 +- .../lib/platform-storage/platform-runtime.ts | 24 - .../platform-storage/tauri-key-value-store.ts | 107 +-- .../core/lib/services/common/api-client.ts | 38 +- .../core/lib/services/common/atom-client.ts | 9 - .../lib/services/common/rpc-atom-client.ts | 80 -- .../src/core/lib/services/common/runtime.ts | 35 - .../src/core/lib/services/common/telemetry.ts | 10 - apps/web-foldkit/src/data/actions.test.ts | 125 ++-- apps/web-foldkit/src/data/actions.ts | 131 ++-- apps/web-foldkit/src/data/storage.ts | 6 +- apps/web-foldkit/src/emoji-picker/usage.ts | 12 - apps/web-foldkit/src/gif-picker/picker.ts | 15 +- .../src/notification-sound.test.ts | 41 +- apps/web-foldkit/src/notification-sound.ts | 31 +- .../src/overlay/command-palette/commands.ts | 6 +- .../src/overlay/modal/create-section.ts | 4 +- .../src/overlay/modal/delete-channel.ts | 4 +- .../src/overlay/modal/delete-message.ts | 3 +- .../src/overlay/modal/join-channel.ts | 4 +- .../src/overlay/modal/new-channel.ts | 4 +- .../src/overlay/modal/rename-channel.ts | 5 +- .../src/overlay/modal/rename-thread.ts | 1 - apps/web-foldkit/src/page/README.md | 19 +- .../channel-settings/integrations/command.ts | 7 +- .../page/chat/channel/actions.story.test.ts | 9 +- .../page/chat/channel/messages.scene.test.ts | 6 +- .../src/page/chat/channel/model.ts | 4 +- .../src/page/chat/channel/subscription.ts | 4 +- .../src/page/chat/channel/write.ts | 9 +- .../src/page/chat/message-actions.ts | 25 +- apps/web-foldkit/src/page/join/command.ts | 3 +- .../src/page/settings/custom-emojis/update.ts | 8 +- .../src/page/settings/general/update.ts | 8 +- .../src/shell/channels-sidebar/actions.ts | 8 +- docs/foldkit-decisions/s6-errors.md | 105 +++ .../src/collection.ts | 2 +- .../src/optimistic-action.ts | 201 ++--- 56 files changed, 876 insertions(+), 2079 deletions(-) delete mode 100644 apps/web-foldkit/src/core/atoms/emoji-atoms.ts delete mode 100644 apps/web-foldkit/src/core/atoms/notification-sound-atoms.test.ts delete mode 100644 apps/web-foldkit/src/core/atoms/notification-sound-atoms.ts delete mode 100644 apps/web-foldkit/src/core/lib/notifications/diagnostics-store.ts delete mode 100644 apps/web-foldkit/src/core/lib/notifications/sinks/in-app.ts delete mode 100644 apps/web-foldkit/src/core/lib/platform-storage/platform-runtime.ts delete mode 100644 apps/web-foldkit/src/core/lib/services/common/atom-client.ts delete mode 100644 apps/web-foldkit/src/core/lib/services/common/rpc-atom-client.ts delete mode 100644 apps/web-foldkit/src/core/lib/services/common/runtime.ts delete mode 100644 apps/web-foldkit/src/core/lib/services/common/telemetry.ts delete mode 100644 apps/web-foldkit/src/emoji-picker/usage.ts create mode 100644 docs/foldkit-decisions/s6-errors.md diff --git a/apps/web-foldkit/src/app/notification-sinks.ts b/apps/web-foldkit/src/app/notification-sinks.ts index db96ea467..fc1348465 100644 --- a/apps/web-foldkit/src/app/notification-sinks.ts +++ b/apps/web-foldkit/src/app/notification-sinks.ts @@ -1,16 +1,14 @@ 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 { 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 @@ -24,7 +22,7 @@ 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 } @@ -53,21 +51,22 @@ export const wireNotificationSinks = ({ notificationOrchestrator.setDependencies({ getContext: () => { const userSettings = userCollection.state.get(userId)?.settings + const now = Date.now() return { currentChannelId, sessionStartTime, - isMuted: createIsMutedGetter( + isMuted: isSoundMuted({ settings, - userSettings?.doNotDisturb ?? false, - userSettings?.quietHoursStart ?? "22:00", - userSettings?.quietHoursEnd ?? "08:00", - )(), + doNotDisturb: userSettings?.doNotDisturb ?? false, + quietHoursStart: userSettings?.quietHoursStart ?? "22:00", + quietHoursEnd: userSettings?.quietHoursEnd ?? "08:00", + nowMs: now, + }), isWindowFocused: document.hasFocus(), visibilityState: document.visibilityState, - now: Date.now(), + now, } }, - inAppSink: inAppNotificationSink, soundSink, nativeSink, }) diff --git a/apps/web-foldkit/src/chat/message/toolbar.ts b/apps/web-foldkit/src/chat/message/toolbar.ts index 80252b099..d59691a5b 100644 --- a/apps/web-foldkit/src/chat/message/toolbar.ts +++ b/apps/web-foldkit/src/chat/message/toolbar.ts @@ -12,7 +12,7 @@ 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 diff --git a/apps/web-foldkit/src/composer/draft-update.ts b/apps/web-foldkit/src/composer/draft-update.ts index 50a38a3a1..92fd328dc 100644 --- a/apps/web-foldkit/src/composer/draft-update.ts +++ b/apps/web-foldkit/src/composer/draft-update.ts @@ -2,7 +2,7 @@ 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 type { HazelApiClient, HazelRpc } from "../rpc" import * as Composer from "./composer" import * as EditorCommands from "./editor-commands" import { @@ -17,7 +17,6 @@ import { SendMessage, ExecuteBotCommand, fileInputId, - TrackEmojiUsage, } from "./draft" import * as EmojiDialog from "../emoji-picker/dialog" import * as GifPicker from "../gif-picker/picker" @@ -28,7 +27,7 @@ import { UploadEvent } from "./upload" /** The draft's update: `ComposerEditor`'s submit, edit and escape handling plus the upload queue. */ -export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, HazelRpc> +export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, HazelRpc | HazelApiClient> const editorIdOf = (model: Model) => model.composer.editorId @@ -45,7 +44,7 @@ const liftTyping = (model: Model, result: Typing.Return): Return => ({ commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotTypingMessage({ message })), }) -const withCommands = (result: Return, commands: ReadonlyArray<Command.Command<Message, never, HazelRpc>>): Return => ({ +const withCommands = (result: Return, commands: ReadonlyArray<Command.Command<Message, never, HazelRpc | HazelApiClient>>): Return => ({ ...result, commands: [...(result.commands ?? []), ...commands], }) @@ -259,9 +258,8 @@ export const update = (model: Model, message: Message, context: Context): Return 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 ?? [])] } + return { ...inserted, commands: [...(next.commands ?? []), ...(inserted.commands ?? [])] } }, - CompletedTrackEmojiUsage: () => ({ model }), GotGifPickerMessage: ({ message: gifMessage }) => { const result = GifPicker.update(model.gifPicker, gifMessage) const next: Return = { diff --git a/apps/web-foldkit/src/composer/draft.ts b/apps/web-foldkit/src/composer/draft.ts index 5b380d3e0..f4bd961a0 100644 --- a/apps/web-foldkit/src/composer/draft.ts +++ b/apps/web-foldkit/src/composer/draft.ts @@ -11,16 +11,15 @@ import { Cause, Effect, Schema } from "effect" import { Command, File } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { editMessageAction, sendMessageAction } from "~/db/actions" -import { HazelApiClient } from "~/lib/services/common/atom-client" +import { HazelApiClient } from "../rpc" import { ToastRequest } from "../overlay/toasts" -import { errorToast, failureToast, runAtomFn, successToast, toastOfExit } from "../data/actions" +import { commonMessage } from "~/lib/error-messages" +import { catchCommon, errorToast, failureToast, successToast, toastOfExit } from "../data/actions" import { notRetryable, rateLimited } from "../page/chat/action-effects" 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 { UploadEvent } from "./upload" @@ -118,7 +117,6 @@ export const Message = defineMessageUnion({ SelectedGif: { url: Schema.String }, GotEmojiPickerMessage: { message: EmojiDialog.Message }, GotGifPickerMessage: { message: GifPicker.Message }, - CompletedTrackEmojiUsage: {}, CompletedExecuteBotCommand: { succeeded: Schema.Boolean, toast: ToastRequest }, }) export type Message = typeof Message.Type @@ -157,7 +155,7 @@ export const SendMessage = Command.define("SendMessage", { }, messages: [Message.SucceededSendMessage, Message.FailedSendMessage], execute: ({ restoreContent, ...variables }) => - Effect.matchCause(runAtomFn(sendMessageAction, { ...variables, attachmentIds: [...variables.attachmentIds] }), { + Effect.matchCause(sendMessageAction({ ...variables, attachmentIds: [...variables.attachmentIds] }), { onSuccess: () => Message.SucceededSendMessage(), onFailure: (cause) => Message.FailedSendMessage({ @@ -181,7 +179,7 @@ export const EditMessage = Command.define("EditMessage", { args: { messageId: MessageId, content: Schema.String }, messages: [Message.CompletedEditMessage], execute: (args) => - toastOfExit(runAtomFn(editMessageAction, args), "exitToast", { + toastOfExit(editMessageAction(args), "exitToast", { handlers: { RateLimitExceededError: rateLimited("trying again"), MessageNotFoundError: notRetryable("Message not found", "This message may have been deleted."), @@ -203,10 +201,8 @@ export const OpenFilePicker = Command.define("OpenFilePicker", { Effect.sync(() => document.getElementById(inputId)?.click()).pipe(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 +const failedBotCommand = (title: string, description?: string) => + Message.CompletedExecuteBotCommand({ succeeded: false, toast: errorToast(title, description ?? null) }) /** The slash command panel's Execute (`bot-commands.executeBotCommand`). */ export const ExecuteBotCommand = Command.define("ExecuteBotCommand", { @@ -220,43 +216,25 @@ export const ExecuteBotCommand = Command.define("ExecuteBotCommand", { }, messages: [Message.CompletedExecuteBotCommand], execute: ({ orgId, botId, commandName, botName, channelId, arguments: args }) => - Effect.matchCause( - runAtomFn(executeBotCommand, { + HazelApiClient.use((client) => + client["bot-commands"].executeBotCommand({ params: { orgId, botId: BotId.make(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), - }) - }, - }, + ).pipe( + Effect.as( + Message.CompletedExecuteBotCommand({ succeeded: true, toast: successToast(`Executed /${commandName}`) }), + ), + Effect.catchTags({ + BotNotFoundError: () => Effect.succeed(failedBotCommand(`Bot "${botName}" was not found`)), + BotNotInstalledError: () => Effect.succeed(failedBotCommand(`Bot "${botName}" is not installed`)), + BotCommandNotFoundError: () => Effect.succeed(failedBotCommand(`Command /${commandName} not found`)), + BotCommandExecutionError: () => + Effect.succeed(failedBotCommand(`/${commandName} failed`, "The bot reported an error running the command.")), + }), + catchCommon((reason) => { + const message = commonMessage(reason) + return failedBotCommand(message.title, message.description) + }), ), }) - -/** `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())), -}) diff --git a/apps/web-foldkit/src/composer/upload.ts b/apps/web-foldkit/src/composer/upload.ts index 2d3d0fa74..f593fecb1 100644 --- a/apps/web-foldkit/src/composer/upload.ts +++ b/apps/web-foldkit/src/composer/upload.ts @@ -1,11 +1,10 @@ import { AttachmentId, ChannelId, OrganizationId } from "@hazel/schema" import { Effect, Exit, 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 { HazelApiClient, HazelRpc } from "../rpc" +import { errorToast } from "../data/actions" /** * Port of `useFileUpload().uploadFile`: presign (`uploads.presign`), PUT with progress, then @@ -32,8 +31,6 @@ export interface UploadArgs { readonly channelId: ChannelId } -const presign = HazelApiClient.mutation("uploads", "presign") - const markFailed = (id: AttachmentId, reason: string) => Effect.gen(function* () { const client = yield* HazelRpc @@ -47,16 +44,18 @@ const upload = (args: UploadArgs, onProgress: (percent: number) => void) => 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, - }, - }), + 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, + }, + }), + ), ) if (Exit.isFailure(presigned)) { return { attachmentId: null, toast: errorToast("Upload failed", "Failed to get upload URL. Please try again.") } @@ -93,8 +92,8 @@ const upload = (args: UploadArgs, onProgress: (percent: number) => void) => }) /** The upload of one file as events: progress percentages, then `FinishedUpload`. */ -export const uploadStream = (args: UploadArgs): Stream.Stream<UploadEvent, never, HazelRpc> => - Stream.callback<UploadEvent, never, HazelRpc>((queue) => +export const uploadStream = (args: UploadArgs): Stream.Stream<UploadEvent, never, HazelRpc | HazelApiClient> => + Stream.callback<UploadEvent, never, HazelRpc | HazelApiClient>((queue) => upload(args, (progress) => Queue.offerUnsafe(queue, UploadEvent.ProgressedUpload({ fileId: args.fileId, progress }))).pipe( Effect.catchCause(() => Effect.succeed({ 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<Date>(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/db/actions.ts b/apps/web-foldkit/src/core/db/actions.ts index 43c2c00fc..1ac710451 100644 --- a/apps/web-foldkit/src/core/db/actions.ts +++ b/apps/web-foldkit/src/core/db/actions.ts @@ -1,7 +1,7 @@ import { type User } from "@hazel/domain/models" -import type { - AttachmentId, - ChannelIcon, +import { + type AttachmentId, + type ChannelIcon, ChannelId, ChannelMemberId, ChannelSectionId, @@ -10,23 +10,13 @@ import type { MessageReactionId, OrganizationId, PinnedMessageId, - UserId, + type UserId, } from "@hazel/schema" -import { Cause, Effect, Schedule, Duration } from "effect" +import { Effect, Schedule, Duration } from "effect" import { isRetryableError } from "~/lib/error-messages" - -/** - * Wraps an error in a Cause and checks if it's retryable. - * Used by Schedule.whileInput which provides raw errors. - */ -const isErrorRetryable = (error: unknown): boolean => { - const cause = Cause.fail(error) - return isRetryableError(cause) -} -import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client" -import { runtime } from "~/lib/services/common/runtime" import { getSharedConversationIdForChannel } from "~/lib/connect-shared-channels" import { optimisticAction } from "@hazel/effect-electric-db-collection" +import { HazelRpc } from "../../rpc" import { attachmentCollection, channelCollection, @@ -41,6 +31,11 @@ import { userCollection, } from "./collections" +/** + * The optimistic actions: each is `(variables) => Effect` that applies the change to the collections at once, + * runs the RPC through `HazelRpc`, waits for Electric to sync it, and rolls back on failure. + */ + const getMountedConversationId = (channelId: ChannelId) => getSharedConversationIdForChannel(channelId, connectConversationChannelCollection.state.values()) @@ -54,7 +49,7 @@ const getMountedConversationId = (channelId: ChannelId) => */ const MessageRetrySchedule = Schedule.exponential(Duration.seconds(1), 2).pipe( Schedule.jittered, - Schedule.while((metadata) => isErrorRetryable(metadata.input)), + Schedule.while((metadata) => isRetryableError(metadata.input)), (schedule) => Schedule.max([schedule, Schedule.recurs(3)]), ) @@ -64,7 +59,6 @@ export const sendMessageAction = optimisticAction({ // modified (i.e., when attachments are provided). Syncing on messageCollection alone is sufficient // to confirm the transaction committed. collections: [messageCollection], - runtime: runtime, onMutate: (props: { messageId?: MessageId // Accept pre-generated ID for retry tracking @@ -74,9 +68,8 @@ export const sendMessageAction = optimisticAction({ replyToMessageId?: MessageId | null threadChannelId?: ChannelId | null attachmentIds?: AttachmentId[] - onRetryAttempt?: (attempt: number) => void }) => { - const messageId = props.messageId ?? (crypto.randomUUID() as MessageId) + const messageId = props.messageId ?? MessageId.make(crypto.randomUUID()) messageCollection.insert({ id: messageId, @@ -106,14 +99,7 @@ export const sendMessageAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient - - // Create retry schedule with optional attempt callback - const scheduleWithCallback = props.onRetryAttempt - ? MessageRetrySchedule.pipe( - Schedule.tap(({ attempt }) => Effect.sync(() => props.onRetryAttempt!(attempt))), - ) - : MessageRetrySchedule + const client = yield* HazelRpc // Create the message with attachmentIds using RPC // Note: authorId will be overridden by backend AuthMiddleware with the authenticated user @@ -126,17 +112,8 @@ export const sendMessageAction = optimisticAction({ embeds: null, deletedAt: null, authorId: props.authorId, - }).pipe(Effect.retry(scheduleWithCallback)) - - // No manual sync needed - automatic sync on messageCollection! - console.debug( - `[txid-debug] sendMessage RPC result.transactionId:`, - result.transactionId, - `type:`, - typeof result.transactionId, - `full result keys:`, - Object.keys(result), - ) + }).pipe(Effect.retry(MessageRetrySchedule)) + return { data: result, transactionId: result.transactionId } }), }) @@ -145,7 +122,6 @@ export const createChannelAction = optimisticAction({ // Skip sync wait - TanStack awaitTxId hangs during Electric shape conflicts (409 errors) // Optimistic inserts + RPC still work correctly collections: [], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId name: string @@ -155,7 +131,7 @@ export const createChannelAction = optimisticAction({ currentUserId: UserId addAllMembers?: boolean }) => { - const channelId = crypto.randomUUID() as ChannelId + const channelId = ChannelId.make(crypto.randomUUID()) const now = new Date() // Optimistically insert the channel @@ -174,7 +150,7 @@ export const createChannelAction = optimisticAction({ // Add creator as member channelMemberCollection.insert({ - id: crypto.randomUUID() as ChannelMemberId, + id: ChannelMemberId.make(crypto.randomUUID()), channelId: channelId, userId: props.currentUserId, isHidden: false, @@ -192,7 +168,7 @@ export const createChannelAction = optimisticAction({ mutate: (props, ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channel.create", { id: ctx.mutateResult.channelId, name: props.name, @@ -212,7 +188,6 @@ export const createDmChannelAction = optimisticAction({ channel: channelCollection, members: channelMemberCollection, }, - runtime: runtime, onMutate: (props: { organizationId: OrganizationId @@ -221,7 +196,7 @@ export const createDmChannelAction = optimisticAction({ name?: string currentUserId: UserId }) => { - const channelId = crypto.randomUUID() as ChannelId + const channelId = ChannelId.make(crypto.randomUUID()) const now = new Date() let channelName = props.name @@ -245,7 +220,7 @@ export const createDmChannelAction = optimisticAction({ // Add current user as member channelMemberCollection.insert({ - id: crypto.randomUUID() as ChannelMemberId, + id: ChannelMemberId.make(crypto.randomUUID()), channelId: channelId, userId: props.currentUserId, isHidden: false, @@ -261,7 +236,7 @@ export const createDmChannelAction = optimisticAction({ // Add all participants as members for (const participantId of props.participantIds) { channelMemberCollection.insert({ - id: crypto.randomUUID() as ChannelMemberId, + id: ChannelMemberId.make(crypto.randomUUID()), channelId: channelId, userId: participantId, isHidden: false, @@ -280,7 +255,7 @@ export const createDmChannelAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channel.createDm", { organizationId: props.organizationId, @@ -299,10 +274,9 @@ export const createDmChannelAction = optimisticAction({ export const createOrganizationAction = optimisticAction({ collections: [organizationCollection], - runtime: runtime, onMutate: (props: { name: string; slug: string; logoUrl?: string | null }) => { - const organizationId = crypto.randomUUID() as OrganizationId + const organizationId = OrganizationId.make(crypto.randomUUID()) const now = new Date() organizationCollection.insert({ @@ -322,7 +296,7 @@ export const createOrganizationAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("organization.create", { name: props.name, slug: props.slug, @@ -344,7 +318,6 @@ export const createOrganizationAction = optimisticAction({ export const toggleReactionAction = optimisticAction({ collections: [messageReactionCollection], - runtime: runtime, onMutate: (props: { messageId: MessageId; channelId: ChannelId; emoji: string; userId: UserId }) => { // Check if reaction already exists in the collection @@ -360,7 +333,7 @@ export const toggleReactionAction = optimisticAction({ } // Toggle on: insert a new reaction - const reactionId = crypto.randomUUID() as MessageReactionId + const reactionId = MessageReactionId.make(crypto.randomUUID()) messageReactionCollection.insert({ id: reactionId, messageId: props.messageId, @@ -376,7 +349,7 @@ export const toggleReactionAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc // Call the toggle RPC endpoint const result = yield* client("messageReaction.toggle", { @@ -397,7 +370,6 @@ export const createThreadAction = optimisticAction({ channel: channelCollection, members: channelMemberCollection, }, - runtime: runtime, onMutate: (props: { threadChannelId?: ChannelId @@ -406,7 +378,7 @@ export const createThreadAction = optimisticAction({ organizationId: OrganizationId currentUserId: UserId }) => { - const threadChannelId = props.threadChannelId ?? (crypto.randomUUID() as ChannelId) + const threadChannelId = props.threadChannelId ?? ChannelId.make(crypto.randomUUID()) const now = new Date() // Create thread channel @@ -425,7 +397,7 @@ export const createThreadAction = optimisticAction({ // Add creator as member channelMemberCollection.insert({ - id: crypto.randomUUID() as ChannelMemberId, + id: ChannelMemberId.make(crypto.randomUUID()), channelId: threadChannelId, userId: props.currentUserId, isHidden: false, @@ -448,7 +420,7 @@ export const createThreadAction = optimisticAction({ mutate: (props, ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc // Call dedicated thread creation endpoint that atomically: // 1. Creates the thread channel @@ -467,15 +439,9 @@ export const createThreadAction = optimisticAction({ }), }) -/** - * Updates Hazel-specific user preferences (timezone, settings). - * Identity fields (firstName/lastName/avatarUrl) live in Clerk — update them - * via `useUser().user.update(...)` / `setProfileImage(...)` and let the Clerk - * webhook sync them back. - */ +/** Updates the user's Hazel preferences (timezone, settings). Profile settings save names and avatar via `user.update`. */ export const updateUserAction = optimisticAction({ collections: [userCollection], - runtime: runtime, onMutate: (props: { userId: UserId; timezone?: string | null; settings?: User.UserSettings | null }) => { userCollection.update(props.userId, (draft) => { @@ -488,7 +454,7 @@ export const updateUserAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("user.update", { id: props.userId, @@ -502,7 +468,6 @@ export const updateUserAction = optimisticAction({ export const editMessageAction = optimisticAction({ collections: [messageCollection], - runtime: runtime, onMutate: (props: { messageId: MessageId; content: string }) => { messageCollection.update(props.messageId, (message) => { @@ -514,7 +479,7 @@ export const editMessageAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("message.update", { id: props.messageId, content: props.content, @@ -525,7 +490,6 @@ export const editMessageAction = optimisticAction({ export const deleteMessageAction = optimisticAction({ collections: [messageCollection], - runtime: runtime, onMutate: (props: { messageId: MessageId }) => { messageCollection.delete(props.messageId) @@ -534,7 +498,7 @@ export const deleteMessageAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("message.delete", { id: props.messageId }) return { data: result, transactionId: result.transactionId } }), @@ -542,10 +506,9 @@ export const deleteMessageAction = optimisticAction({ export const pinMessageAction = optimisticAction({ collections: [pinnedMessageCollection], - runtime: runtime, onMutate: (props: { messageId: MessageId; channelId: ChannelId; userId: UserId }) => { - const pinnedMessageId = crypto.randomUUID() as PinnedMessageId + const pinnedMessageId = PinnedMessageId.make(crypto.randomUUID()) pinnedMessageCollection.insert({ id: pinnedMessageId, channelId: props.channelId, @@ -558,7 +521,7 @@ export const pinMessageAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("pinnedMessage.create", { channelId: props.channelId, messageId: props.messageId, @@ -569,7 +532,6 @@ export const pinMessageAction = optimisticAction({ export const unpinMessageAction = optimisticAction({ collections: [pinnedMessageCollection], - runtime: runtime, onMutate: (props: { pinnedMessageId: PinnedMessageId }) => { pinnedMessageCollection.delete(props.pinnedMessageId) @@ -578,7 +540,7 @@ export const unpinMessageAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("pinnedMessage.delete", { id: props.pinnedMessageId }) return { data: result, transactionId: result.transactionId } }), @@ -586,7 +548,6 @@ export const unpinMessageAction = optimisticAction({ export const updateChannelAction = optimisticAction({ collections: [channelCollection], - runtime: runtime, onMutate: (props: { channelId: ChannelId; name: string }) => { channelCollection.update(props.channelId, (channel) => { @@ -597,7 +558,7 @@ export const updateChannelAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channel.update", { id: props.channelId, name: props.name, @@ -608,7 +569,6 @@ export const updateChannelAction = optimisticAction({ export const deleteChannelAction = optimisticAction({ collections: [channelCollection], - runtime: runtime, onMutate: (props: { channelId: ChannelId }) => { channelCollection.delete(props.channelId) @@ -617,7 +577,7 @@ export const deleteChannelAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channel.delete", { id: props.channelId }) return { data: result, transactionId: result.transactionId } }), @@ -625,10 +585,9 @@ export const deleteChannelAction = optimisticAction({ export const joinChannelAction = optimisticAction({ collections: [], - runtime: runtime, onMutate: (props: { channelId: ChannelId; userId: UserId }) => { - const memberId = crypto.randomUUID() as ChannelMemberId + const memberId = ChannelMemberId.make(crypto.randomUUID()) const now = new Date() channelMemberCollection.insert({ id: memberId, @@ -648,7 +607,7 @@ export const joinChannelAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelMember.create", { channelId: props.channelId, }) @@ -658,7 +617,6 @@ export const joinChannelAction = optimisticAction({ export const updateChannelMemberAction = optimisticAction({ collections: [channelMemberCollection], - runtime: runtime, onMutate: (props: { memberId: ChannelMemberId @@ -676,7 +634,7 @@ export const updateChannelMemberAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelMember.update", { id: props.memberId, ...(props.isMuted !== undefined && { isMuted: props.isMuted }), @@ -691,10 +649,9 @@ export const updateChannelMemberAction = optimisticAction({ export const createChannelSectionAction = optimisticAction({ collections: [channelSectionCollection], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId; name: string; order?: number }) => { - const sectionId = crypto.randomUUID() as ChannelSectionId + const sectionId = ChannelSectionId.make(crypto.randomUUID()) const now = new Date() channelSectionCollection.insert({ @@ -712,7 +669,7 @@ export const createChannelSectionAction = optimisticAction({ mutate: (props, ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelSection.create", { id: ctx.mutateResult.sectionId, organizationId: props.organizationId, @@ -725,7 +682,6 @@ export const createChannelSectionAction = optimisticAction({ export const updateChannelSectionAction = optimisticAction({ collections: [channelSectionCollection], - runtime: runtime, onMutate: (props: { sectionId: ChannelSectionId; name?: string; order?: number }) => { channelSectionCollection.update(props.sectionId, (section) => { @@ -737,7 +693,7 @@ export const updateChannelSectionAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelSection.update", { id: props.sectionId, ...(props.name !== undefined && { name: props.name }), @@ -752,7 +708,6 @@ export const deleteChannelSectionAction = optimisticAction({ section: channelSectionCollection, channel: channelCollection, }, - runtime: runtime, onMutate: (props: { sectionId: ChannelSectionId }) => { // Move all channels in this section back to default (sectionId = null) @@ -772,7 +727,7 @@ export const deleteChannelSectionAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelSection.delete", { id: props.sectionId }) return { data: result, transactionId: result.transactionId } }), @@ -780,7 +735,6 @@ export const deleteChannelSectionAction = optimisticAction({ export const moveChannelToSectionAction = optimisticAction({ collections: [channelCollection], - runtime: runtime, onMutate: (props: { channelId: ChannelId; sectionId: ChannelSectionId | null }) => { channelCollection.update(props.channelId, (channel) => { @@ -791,7 +745,7 @@ export const moveChannelToSectionAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelSection.moveChannel", { channelId: props.channelId, sectionId: props.sectionId, @@ -802,7 +756,6 @@ export const moveChannelToSectionAction = optimisticAction({ export const reorderSectionsAction = optimisticAction({ collections: [channelSectionCollection], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId; sectionIds: ChannelSectionId[] }) => { // Update order for each section @@ -817,7 +770,7 @@ export const reorderSectionsAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("channelSection.reorder", { organizationId: props.organizationId, sectionIds: props.sectionIds, @@ -828,7 +781,6 @@ export const reorderSectionsAction = optimisticAction({ export const updateOrganizationAction = optimisticAction({ collections: [organizationCollection], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId; name?: string; logoUrl?: string | null }) => { organizationCollection.update(props.organizationId, (org) => { @@ -840,7 +792,7 @@ export const updateOrganizationAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("organization.update", { id: props.organizationId, ...(props.name !== undefined && { name: props.name }), @@ -852,7 +804,6 @@ export const updateOrganizationAction = optimisticAction({ export const setPublicModeAction = optimisticAction({ collections: [organizationCollection], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId; isPublic: boolean }) => { organizationCollection.update(props.organizationId, (org) => { @@ -863,7 +814,7 @@ export const setPublicModeAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("organization.setPublicMode", { id: props.organizationId, isPublic: props.isPublic, @@ -876,7 +827,6 @@ export const setPublicModeAction = optimisticAction({ export const createCustomEmojiAction = optimisticAction({ collections: [customEmojiCollection], - runtime: runtime, onMutate: (props: { organizationId: OrganizationId @@ -884,7 +834,7 @@ export const createCustomEmojiAction = optimisticAction({ imageUrl: string createdBy: UserId }) => { - const emojiId = crypto.randomUUID() as CustomEmojiId + const emojiId = CustomEmojiId.make(crypto.randomUUID()) const now = new Date() customEmojiCollection.insert({ @@ -903,7 +853,7 @@ export const createCustomEmojiAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("customEmoji.create", { organizationId: props.organizationId, name: props.name, @@ -915,7 +865,6 @@ export const createCustomEmojiAction = optimisticAction({ export const restoreCustomEmojiAction = optimisticAction({ collections: [customEmojiCollection], - runtime: runtime, onMutate: (props: { emojiId: CustomEmojiId @@ -941,7 +890,7 @@ export const restoreCustomEmojiAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("customEmoji.restore", { id: props.emojiId, imageUrl: props.imageUrl, @@ -952,7 +901,6 @@ export const restoreCustomEmojiAction = optimisticAction({ export const deleteCustomEmojiAction = optimisticAction({ collections: [customEmojiCollection], - runtime: runtime, onMutate: (props: { emojiId: CustomEmojiId }) => { customEmojiCollection.update(props.emojiId, (emoji) => { @@ -963,7 +911,7 @@ export const deleteCustomEmojiAction = optimisticAction({ mutate: (props, _ctx) => Effect.gen(function* () { - const client = yield* HazelRpcClient + const client = yield* HazelRpc const result = yield* client("customEmoji.delete", { id: props.emojiId }) return { data: result, transactionId: result.transactionId } }), diff --git a/apps/web-foldkit/src/core/db/collections.ts b/apps/web-foldkit/src/core/db/collections.ts index 8233cc329..b4b5483df 100644 --- a/apps/web-foldkit/src/core/db/collections.ts +++ b/apps/web-foldkit/src/core/db/collections.ts @@ -24,486 +24,79 @@ import { User, UserPresenceStatus, } from "@hazel/domain/models" +import type { Schema } from "effect" import { electricFetchClient, electricOnError } from "~/lib/electric-fetch" -import { runtime } from "~/lib/services/common/runtime" -import { createEffectCollection } from "@hazel/effect-electric-db-collection" +import { createEffectCollection, type EffectCollection } from "@hazel/effect-electric-db-collection" const electricUrl = import.meta.env.VITE_ELECTRIC_URL -export const organizationCollection = createEffectCollection({ - id: "organizations", - runtime: runtime, - backoff: false, // Retries: the Electric client (5xx, 429, network) and `electricOnError` (401) - shapeOptions: { - url: electricUrl, - //liveSse: true, - - params: { - table: "organizations", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Organization.Schema, - getKey: (item) => item.id, -}) - -export const messageCollection = createEffectCollection({ - id: "messages", - // syncMode: "on-demand", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "messages", - }, - //// liveSse: true, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Message.Schema, - getKey: (item) => item.id, -}) - -export const messageReactionCollection = createEffectCollection({ - id: "message_reactions", +interface ElectricCollectionOptions { + /** TanStack DB's sync mode; the default loads the whole shape eagerly. */ + readonly syncMode?: "eager" | "on-demand" + /** Parse `timestamptz` columns into `Date`s before the schema sees them (default true). */ + readonly parseTimestamps?: boolean +} + +/** + * One Electric-synced table: the shape is the table itself (the proxy scopes it to the user), rows are keyed by + * `id`. Retries: the Electric client (5xx, 429, network) and `electricOnError` (401), so collection backoff is off. + */ +const electricCollection = <A extends { readonly id: string }>( + table: string, + schema: Schema.Schema<A>, + { syncMode, parseTimestamps = true }: ElectricCollectionOptions = {}, +): EffectCollection<A> => + createEffectCollection({ + id: table, + ...(syncMode === undefined ? {} : { syncMode }), + backoff: false, + shapeOptions: { + url: electricUrl, + params: { table }, + // The parser's value type follows the row, which a generic row can't name; `Schema.JsonDate` + // columns accept the string form too, so this is only the fast path. + ...(parseTimestamps ? { parser: { timestamptz: ((date: string) => new Date(date)) as never } } : {}), + fetchClient: electricFetchClient, + onError: electricOnError, + }, + schema, + getKey: (item) => item.id, + }) + +export const organizationCollection = electricCollection("organizations", Organization.Schema) +export const messageCollection = electricCollection("messages", Message.Schema) +export const messageReactionCollection = electricCollection("message_reactions", MessageReaction.Schema, { syncMode: "on-demand", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "message_reactions", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: MessageReaction.Schema, - getKey: (item) => item.id, }) - -export const pinnedMessageCollection = createEffectCollection({ - id: "pinned_messages", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "pinned_messages", - }, - //liveSse: true, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: PinnedMessage.Schema, - getKey: (item) => item.id, -}) - -export const notificationCollection = createEffectCollection({ - id: "notifications", +export const pinnedMessageCollection = electricCollection("pinned_messages", PinnedMessage.Schema) +export const notificationCollection = electricCollection("notifications", Notification.Schema, { syncMode: "on-demand", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "notifications", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Notification.Schema, - getKey: (item) => item.id, -}) - -export const userCollection = createEffectCollection({ - id: "users", - // syncMode: "progressive", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //// liveSse: true, - params: { - table: "users", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: User.Schema, - getKey: (item) => item.id, -}) - -export const organizationMemberCollection = createEffectCollection({ - id: "organization_members", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "organization_members", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: OrganizationMember.Schema, - getKey: (item) => item.id, -}) - -export const channelCollection = createEffectCollection({ - id: "channels", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "channels", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Channel.Schema, - getKey: (item) => item.id, -}) - -export const connectConversationCollection = createEffectCollection({ - id: "connect_conversations", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "connect_conversations", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ConnectConversation.Schema, - getKey: (item) => item.id, -}) - -export const connectConversationChannelCollection = createEffectCollection({ - id: "connect_conversation_channels", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "connect_conversation_channels", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ConnectConversationChannel.Schema, - getKey: (item) => item.id, -}) - -export const connectParticipantCollection = createEffectCollection({ - id: "connect_participants", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "connect_participants", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ConnectParticipant.Schema, - getKey: (item) => item.id, -}) - -export const channelMemberCollection = createEffectCollection({ - id: "channel_members", - // syncMode: "progressive", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "channel_members", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ChannelMember.Schema, - getKey: (item) => item.id, }) - -export const channelSectionCollection = createEffectCollection({ - id: "channel_sections", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "channel_sections", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ChannelSection.Schema, - getKey: (item) => item.id, -}) - -export const attachmentCollection = createEffectCollection({ - id: "attachments", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "attachments", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Attachment.Schema, - getKey: (item) => item.id, -}) - -export const typingIndicatorCollection = createEffectCollection({ - id: "typing_indicators", +export const userCollection = electricCollection("users", User.Schema) +export const organizationMemberCollection = electricCollection("organization_members", OrganizationMember.Schema) +export const channelCollection = electricCollection("channels", Channel.Schema) +export const connectConversationCollection = electricCollection("connect_conversations", ConnectConversation.Schema) +export const connectConversationChannelCollection = electricCollection( + "connect_conversation_channels", + ConnectConversationChannel.Schema, +) +export const connectParticipantCollection = electricCollection("connect_participants", ConnectParticipant.Schema) +export const channelMemberCollection = electricCollection("channel_members", ChannelMember.Schema) +export const channelSectionCollection = electricCollection("channel_sections", ChannelSection.Schema) +export const attachmentCollection = electricCollection("attachments", Attachment.Schema) +export const typingIndicatorCollection = electricCollection("typing_indicators", TypingIndicator.Schema, { syncMode: "eager", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "typing_indicators", - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: TypingIndicator.Schema, - getKey: (item) => item.id, -}) - -export const userPresenceStatusCollection = createEffectCollection({ - id: "user_presence_status", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "user_presence_status", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: UserPresenceStatus.Schema, - getKey: (item) => item.id, -}) - -export const integrationConnectionCollection = createEffectCollection({ - id: "integration_connections", - // syncMode: "on-demand", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - //liveSse: true, - params: { - table: "integration_connections", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: IntegrationConnection.Schema, - getKey: (item) => item.id, -}) - -export const chatSyncConnectionCollection = createEffectCollection({ - id: "chat_sync_connections", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "chat_sync_connections", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ChatSyncConnection.Schema, - getKey: (item) => item.id, -}) - -export const chatSyncChannelLinkCollection = createEffectCollection({ - id: "chat_sync_channel_links", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "chat_sync_channel_links", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ChatSyncChannelLink.Schema, - getKey: (item) => item.id, -}) - -export const chatSyncMessageLinkCollection = createEffectCollection({ - id: "chat_sync_message_links", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "chat_sync_message_links", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: ChatSyncMessageLink.Schema, - getKey: (item) => item.id, -}) - -export const botCollection = createEffectCollection({ - id: "bots", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "bots", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: Bot.Schema, - getKey: (item) => item.id, -}) - -export const botCommandCollection = createEffectCollection({ - id: "bot_commands", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "bot_commands", - }, - // Schema's nested array (`arguments`) narrows Electric's row extension to - // `never`, so `Parser<never>` requires return type `Value<never>` — impossible - // to satisfy. Widening via `as never` is the minimum cast needed; `Schema.JsonDate` - // already accepts string-form dates, so the parser is belt-and-suspenders. - parser: { - timestamptz: ((date: string) => new Date(date)) as never, - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: BotCommand.Schema, - getKey: (item) => item.id, -}) - -export const botInstallationCollection = createEffectCollection({ - id: "bot_installations", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "bot_installations", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: BotInstallation.Schema, - getKey: (item) => item.id, -}) - -export const customEmojiCollection = createEffectCollection({ - id: "custom_emojis", - runtime: runtime, - backoff: false, - shapeOptions: { - url: electricUrl, - params: { - table: "custom_emojis", - }, - parser: { - timestamptz: (date) => new Date(date), - }, - fetchClient: electricFetchClient, - onError: electricOnError, - }, - schema: CustomEmoji.Schema, - getKey: (item) => item.id, -}) + parseTimestamps: false, +}) +export const userPresenceStatusCollection = electricCollection("user_presence_status", UserPresenceStatus.Schema) +export const integrationConnectionCollection = electricCollection( + "integration_connections", + IntegrationConnection.Schema, +) +export const chatSyncConnectionCollection = electricCollection("chat_sync_connections", ChatSyncConnection.Schema) +export const chatSyncChannelLinkCollection = electricCollection("chat_sync_channel_links", ChatSyncChannelLink.Schema) +export const chatSyncMessageLinkCollection = electricCollection("chat_sync_message_links", ChatSyncMessageLink.Schema) +export const botCollection = electricCollection("bots", Bot.Schema) +export const botCommandCollection = electricCollection("bot_commands", BotCommand.Schema) +export const botInstallationCollection = electricCollection("bot_installations", BotInstallation.Schema) +export const customEmojiCollection = electricCollection("custom_emojis", CustomEmoji.Schema) diff --git a/apps/web-foldkit/src/core/lib/error-messages.ts b/apps/web-foldkit/src/core/lib/error-messages.ts index 0d3efe9ee..2706a45e9 100644 --- a/apps/web-foldkit/src/core/lib/error-messages.ts +++ b/apps/web-foldkit/src/core/lib/error-messages.ts @@ -1,38 +1,18 @@ -import { HttpClientError } from "effect/http" -import { RpcClientError } from "effect/rpc" -import { - AIProviderUnavailableError, - AIRateLimitError, - AIResponseParseError, - DesktopConnectionError, - DmChannelAlreadyExistsError, +import type { RateLimitExceededError } from "@hazel/domain" +import type { + ClerkUserFetchError, InternalServerError, InvalidBearerTokenError, - InvalidDesktopStateError, InvalidJwtPayloadError, - MissingAuthCodeError, - OAuthCallbackError, - OAuthTimeoutError, - OriginalMessageNotFoundError, SessionAuthenticationError, SessionExpiredError, SessionLoadError, SessionNotProvidedError, SessionRefreshError, - TauriCommandError, - TauriNotAvailableError, - ThreadChannelNotFoundError, - ThreadContextQueryError, - ThreadNameUpdateError, - TokenDecodeError, - TokenExchangeError, - TokenNotFoundError, - TokenStoreError, UnauthorizedError, WorkflowServiceUnavailableError, } from "@hazel/domain/errors" -import { Cause, Match, Schema } from "effect" -import { +import type { CollectionInErrorEffectError, CollectionSyncEffectError, DuplicateKeyEffectError, @@ -44,517 +24,211 @@ import { TransactionStateEffectError, UndefinedKeyEffectError, } from "@hazel/effect-electric-db-collection" +import { type Cause, Match, Predicate, Schema } from "effect" +import type { HttpClientError } from "effect/http" +import type { RpcClientError } from "effect/rpc" /** - * User-friendly error message configuration + * The app's error convention (`docs/foldkit-decisions/s6-errors.md`): every failure a Command can't name + * itself collapses to a `CommonReason`, `update` turns reasons into copy, and retry policy is decided from + * the reason, apart from the copy. */ -export interface UserErrorMessage { - title: string - description?: string - isRetryable: boolean -} -/** - * Schema union for Schema-based common errors. - * Used for type-safe error matching and runtime validation. - */ -export const CommonAppErrorSchema = Schema.Union([ - // Auth errors (401) - UnauthorizedError, - SessionNotProvidedError, - SessionAuthenticationError, - InvalidJwtPayloadError, - SessionExpiredError, - InvalidBearerTokenError, - // Service errors (503) - SessionLoadError, - SessionRefreshError, - WorkflowServiceUnavailableError, - // Business logic errors - DmChannelAlreadyExistsError, - // Server errors (500) - InternalServerError, - // Infrastructure errors (appear in most RPC calls) - OptimisticActionError, - SyncError, - // TanStack DB errors (permanent - non-retryable) - DuplicateKeyEffectError, - KeyUpdateNotAllowedEffectError, - UndefinedKeyEffectError, - SchemaValidationEffectError, - // TanStack DB errors (recoverable - retryable) - KeyNotFoundEffectError, - CollectionInErrorEffectError, - TransactionStateEffectError, - CollectionSyncEffectError, - // Desktop auth errors - TauriNotAvailableError, - TauriCommandError, - OAuthTimeoutError, - OAuthCallbackError, - MissingAuthCodeError, - TokenStoreError, - TokenNotFoundError, - TokenExchangeError, - TokenDecodeError, - DesktopConnectionError, - InvalidDesktopStateError, - // Thread naming workflow errors - ThreadChannelNotFoundError, - OriginalMessageNotFoundError, - ThreadContextQueryError, - AIProviderUnavailableError, - AIRateLimitError, - AIResponseParseError, - ThreadNameUpdateError, +/** The closed set every RPC, HTTP API and optimistic-action failure collapses to. */ +export const CommonReason = Schema.Literals([ + "Offline", + "Timeout", + "SignedOut", + "RateLimited", + "Unavailable", + "Unexpected", ]) +export type CommonReason = typeof CommonReason.Type /** - * Union of common application errors that have user-friendly messages. - * Entity-specific errors (e.g., ChannelNotFoundError) should be handled - * at the call site where context is available. - * - * Note: ParseError and HttpClientError are not Schema-based, so they're - * added to the type union separately. + * The errors any backend call can fail with besides its own: transport and decoding, the auth middleware, + * the server's catch-alls, and the optimistic-action/collection machinery. */ -export type CommonAppError = - | typeof CommonAppErrorSchema.Type - // Non-Schema errors (still have _tag but not Schema.TaggedError) - | Schema.SchemaError +export type CommonError = + // Transport and decoding | HttpClientError.HttpClientError - -/** - * Static error messages for errors that don't need dynamic content - */ -const ERROR_MESSAGE_MAP: Record<string, UserErrorMessage> = { - // Auth errors (401) - User needs to re-authenticate - UnauthorizedError: { - title: "You don't have permission to do this", - description: "Contact your admin if you need access.", - isRetryable: false, - }, - SessionExpiredError: { - title: "Your session has expired", - description: "Please sign in again to continue.", - isRetryable: false, - }, - SessionNotProvidedError: { - title: "Please sign in to continue", - description: "You need to be signed in to perform this action.", - isRetryable: false, - }, - SessionAuthenticationError: { - title: "Authentication failed", - description: "Please sign in again.", - isRetryable: false, - }, - InvalidJwtPayloadError: { - title: "Invalid session", - description: "Please sign in again.", - isRetryable: false, - }, - InvalidBearerTokenError: { - title: "Invalid authentication", - description: "Please sign in again.", - isRetryable: false, - }, - - // Service errors (503) - User can retry - SessionLoadError: { - title: "Service temporarily unavailable", - description: "We're having trouble connecting. Please try again.", - isRetryable: true, - }, - WorkflowServiceUnavailableError: { - title: "Service temporarily unavailable", - description: "The workflow service is temporarily unavailable. Please try again later.", - isRetryable: true, - }, - SessionRefreshError: { - title: "Session refresh failed", - description: "Please sign in again.", - isRetryable: false, - }, - - // Business logic errors (409) - DmChannelAlreadyExistsError: { - title: "This conversation already exists", - description: "You already have a direct message with this person.", - isRetryable: false, - }, - - // Server errors (500) - InternalServerError: { - title: "Something went wrong", - description: "Please try again later.", - isRetryable: true, - }, - - // Infrastructure errors - generic handling - OptimisticActionError: { - title: "Action failed", - description: "Please try again.", - isRetryable: true, - }, - SyncError: { - title: "Sync failed", - description: "Your changes were saved but sync is delayed.", - isRetryable: false, - }, - RpcClientError: { - title: "Request failed", - description: "Please try again.", - isRetryable: true, - }, - ParseError: { - title: "Invalid response", - description: "Please try again.", - isRetryable: true, - }, - RequestError: { - title: "Connection failed", - description: "Please check your internet connection.", - isRetryable: true, - }, - ResponseError: { - title: "Server error", - description: "Please try again later.", - isRetryable: true, - }, - - // TanStack DB errors - permanent (non-retryable) - DuplicateKeyEffectError: { - title: "Item already exists", - description: "An item with this ID already exists. Please use a different ID.", - isRetryable: false, - }, - KeyUpdateNotAllowedEffectError: { - title: "Cannot change item ID", - description: "Item IDs cannot be modified. Delete and recreate the item instead.", - isRetryable: false, - }, - UndefinedKeyEffectError: { - title: "Missing item ID", - description: "The item is missing a required ID field.", - isRetryable: false, - }, - - // TanStack DB errors - recoverable (retryable) - CollectionInErrorEffectError: { - title: "Collection error", - description: "The data collection is in an error state. Please try refreshing.", - isRetryable: true, - }, - TransactionStateEffectError: { - title: "Transaction error", - description: "The operation couldn't be completed. Please try again.", - isRetryable: true, - }, - CollectionSyncEffectError: { - title: "Sync error", - description: "Failed to sync data. Please try again.", - isRetryable: true, - }, - - // Thread naming workflow errors - ThreadChannelNotFoundError: { - title: "Thread not found", - description: "This thread may have been deleted.", - isRetryable: false, - }, - OriginalMessageNotFoundError: { - title: "Message not found", - description: "The original message could not be found.", - isRetryable: false, - }, - ThreadContextQueryError: { - title: "Database error", - description: "Failed to load thread data. Please try again.", - isRetryable: true, - }, - AIProviderUnavailableError: { - title: "AI service unavailable", - description: "The AI service is temporarily unavailable. Please try again later.", - isRetryable: true, - }, - AIRateLimitError: { - title: "AI rate limited", - description: "Please wait a moment and try again.", - isRetryable: true, - }, - AIResponseParseError: { - title: "AI response error", - description: "The AI returned an unexpected response. Please try again.", - isRetryable: true, - }, - ThreadNameUpdateError: { - title: "Update failed", - description: "Failed to save the thread name. Please try again.", - isRetryable: true, - }, - - // Desktop auth errors - TauriNotAvailableError: { - title: "Desktop app not available", - description: "Please make sure Hazel is running.", - isRetryable: false, - }, - TauriCommandError: { - title: "Desktop command failed", - description: "Please restart Hazel and try again.", - isRetryable: true, - }, - OAuthTimeoutError: { - title: "Sign in timed out", - description: "The sign in process took too long. Please try again.", - isRetryable: true, - }, - OAuthCallbackError: { - title: "Authentication failed", - description: "There was a problem with the sign in process.", - isRetryable: true, - }, - MissingAuthCodeError: { - title: "Authentication incomplete", - description: "No authorization code received. Please try again.", - isRetryable: true, - }, - TokenStoreError: { - title: "Failed to store credentials", - description: "Please try signing in again.", - isRetryable: true, - }, - TokenNotFoundError: { - title: "Not signed in", - description: "Please sign in to continue.", - isRetryable: false, - }, - TokenExchangeError: { - title: "Sign in failed", - description: "Could not complete sign in. Please try again.", - isRetryable: true, - }, - TokenDecodeError: { - title: "Invalid response", - description: "Received an invalid response from the server.", - isRetryable: true, - }, - DesktopConnectionError: { - title: "Could not connect to Hazel", - description: "Make sure Hazel is running on your computer.", - isRetryable: true, - }, - InvalidDesktopStateError: { - title: "Invalid authentication state", - description: "Please try signing in again.", - isRetryable: true, - }, -} - -/** - * Gets error message for errors that need dynamic content based on error properties - */ -function getDynamicErrorMessage(error: CommonAppError): UserErrorMessage | null { - if (!("_tag" in error)) return null - - switch (error._tag) { - case "SchemaValidationEffectError": { - const e = error as typeof SchemaValidationEffectError.Type - return { - title: "Invalid data", - description: e.issues[0]?.message || "The data doesn't match the expected format.", - isRetryable: false, - } - } - case "KeyNotFoundEffectError": { - const e = error as typeof KeyNotFoundEffectError.Type - return { - title: e.operation === "update" ? "Item not found" : "Already deleted", - description: - e.operation === "update" - ? "The item you're trying to update doesn't exist." - : "This item has already been deleted.", - isRetryable: true, - } - } - default: - return null - } -} - -/** - * Gets user-friendly error message for common application errors. - * Uses a tag-based lookup for static messages and dynamic handlers for errors - * that need to inspect error properties. - */ -export function getCommonErrorMessage(error: CommonAppError): UserErrorMessage { - // First check for dynamic error messages - const dynamicMessage = getDynamicErrorMessage(error) - if (dynamicMessage) return dynamicMessage - - // Then check static message map - if ("_tag" in error) { - const staticMessage = ERROR_MESSAGE_MAP[error._tag] - if (staticMessage) return staticMessage - } - - // Fallback for unknown errors - return { - title: "An error occurred", - description: undefined, - isRetryable: false, - } -} - -/** - * Network error message for connection issues - */ -const NETWORK_ERROR_MESSAGE: UserErrorMessage = { - title: "Connection lost", - description: "Check your internet connection and try again.", - isRetryable: true, -} - -/** - * Timeout error message - */ -const TIMEOUT_ERROR_MESSAGE: UserErrorMessage = { - title: "Request timed out", - description: "The server is taking too long to respond. Please try again.", - isRetryable: true, + | RpcClientError.RpcClientError + | Schema.SchemaError + | Cause.TimeoutError + // The auth middleware (`AuthFailure`) + | UnauthorizedError + | SessionNotProvidedError + | SessionAuthenticationError + | SessionExpiredError + | SessionRefreshError + | SessionLoadError + | InvalidJwtPayloadError + | InvalidBearerTokenError + | ClerkUserFetchError + // Server catch-alls + | InternalServerError + | WorkflowServiceUnavailableError + | RateLimitExceededError + // Optimistic actions and TanStack DB + | OptimisticActionError + | SyncError + | DuplicateKeyEffectError + | KeyUpdateNotAllowedEffectError + | UndefinedKeyEffectError + | SchemaValidationEffectError + | KeyNotFoundEffectError + | CollectionInErrorEffectError + | TransactionStateEffectError + | CollectionSyncEffectError + +/** The HTTP-level reason tags an `HttpClientError` (or an RPC's `HttpError.kind`) carries. */ +type HttpReasonTag = HttpClientError.HttpClientErrorReason["_tag"] + +const httpReason = (tag: HttpReasonTag, status: number | undefined): CommonReason => { + if (tag === "TransportError") return "Offline" + if (tag !== "StatusCodeError" || status === undefined) return "Unexpected" + if (status === 401) return "SignedOut" + if (status === 429) return "RateLimited" + if (status === 502 || status === 503 || status === 504) return "Unavailable" + return "Unexpected" } -/** - * Default fallback error message - */ -export const DEFAULT_ERROR_MESSAGE: UserErrorMessage = { - title: "An error occurred", - description: undefined, - isRetryable: false, -} +const rpcClientReason = (error: RpcClientError.RpcClientError): CommonReason => + Match.valueTags(error.reason, { + HttpError: (reason) => httpReason(reason.kind, undefined), + // The socket and worker transports: the connection is gone or never opened. + SocketReadError: (): CommonReason => "Offline", + SocketWriteError: (): CommonReason => "Offline", + SocketOpenError: (): CommonReason => "Offline", + SocketCloseError: (): CommonReason => "Offline", + SocketUpgradeError: (): CommonReason => "Offline", + WorkerSpawnError: (): CommonReason => "Unexpected", + WorkerSendError: (): CommonReason => "Unexpected", + WorkerReceiveError: (): CommonReason => "Unexpected", + WorkerUnknownError: (): CommonReason => "Unexpected", + RpcClientDefect: (): CommonReason => "Unexpected", + }) + +const signedOut = (): CommonReason => "SignedOut" +const unavailable = (): CommonReason => "Unavailable" +const unexpected = (): CommonReason => "Unexpected" + +/** The reason a `CommonError` collapses to. Exhaustive: a new member of the union must be classified here. */ +export const commonReasonOf = (error: CommonError): CommonReason => + Match.valueTags(error, { + HttpClientError: (e) => httpReason(e.reason._tag, e.response?.status), + RpcClientError: rpcClientReason, + SchemaError: unexpected, + TimeoutError: (): CommonReason => "Timeout", + UnauthorizedError: signedOut, + SessionNotProvidedError: signedOut, + SessionAuthenticationError: signedOut, + SessionExpiredError: signedOut, + SessionRefreshError: signedOut, + InvalidJwtPayloadError: signedOut, + InvalidBearerTokenError: signedOut, + SessionLoadError: unavailable, + ClerkUserFetchError: unavailable, + WorkflowServiceUnavailableError: unavailable, + CollectionSyncEffectError: unavailable, + RateLimitExceededError: (): CommonReason => "RateLimited", + // The write may have landed; only the confirmation timed out. + SyncError: (): CommonReason => "Timeout", + InternalServerError: unexpected, + OptimisticActionError: unexpected, + DuplicateKeyEffectError: unexpected, + KeyUpdateNotAllowedEffectError: unexpected, + UndefinedKeyEffectError: unexpected, + SchemaValidationEffectError: unexpected, + KeyNotFoundEffectError: unexpected, + CollectionInErrorEffectError: unexpected, + TransactionStateEffectError: unexpected, + }) + +const commonTags: ReadonlySet<string> = new Set<CommonError["_tag"]>([ + "HttpClientError", + "RpcClientError", + "SchemaError", + "TimeoutError", + "UnauthorizedError", + "SessionNotProvidedError", + "SessionAuthenticationError", + "SessionExpiredError", + "SessionRefreshError", + "InvalidJwtPayloadError", + "InvalidBearerTokenError", + "SessionLoadError", + "ClerkUserFetchError", + "WorkflowServiceUnavailableError", + "CollectionSyncEffectError", + "RateLimitExceededError", + "SyncError", + "InternalServerError", + "OptimisticActionError", + "DuplicateKeyEffectError", + "KeyUpdateNotAllowedEffectError", + "UndefinedKeyEffectError", + "SchemaValidationEffectError", + "KeyNotFoundEffectError", + "CollectionInErrorEffectError", + "TransactionStateEffectError", +]) /** - * Schema.is for Schema-based common errors (fast path) + * Narrows an untyped failure to a `CommonError`. Only for the places that lost the type (a Schedule's input, the + * legacy `failureToast`); a Command already knows its `E` and uses `catchTags` + `catchCommon`. */ -const isSchemaCommonError = Schema.is(CommonAppErrorSchema) +export const isCommonError = (error: unknown): error is CommonError => + Predicate.hasProperty(error, "_tag") && typeof error._tag === "string" && commonTags.has(error._tag) -/** - * Tags for non-Schema errors that are still common - */ -const NON_SCHEMA_COMMON_TAGS = new Set(["ParseError", "HttpClientError", "RpcClientError"]) +/** The reason for any failure value: a `CommonError`'s reason, anything else is `Unexpected`. */ +export const reasonOfUnknown = (error: unknown): CommonReason => + isCommonError(error) ? commonReasonOf(error) : "Unexpected" -/** - * Type guard for CommonAppError. - * Uses Schema.is for Schema-based errors and tag check for others. - */ -export function isCommonAppError(error: unknown): error is CommonAppError { - // Fast path for Schema-based errors - if (isSchemaCommonError(error)) return true +// RETRY POLICY - // Check non-Schema errors by _tag - if (typeof error === "object" && error !== null && "_tag" in error) { - const tag = (error as { _tag: string })._tag - return NON_SCHEMA_COMMON_TAGS.has(tag) - } - return false -} +/** Transient reasons, worth retrying the same request. */ +export const isRetryableReason = (reason: CommonReason): boolean => + reason === "Offline" || reason === "Timeout" || reason === "Unavailable" -/** - * Checks if an error is a network/transport error - */ -function isNetworkError(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false +/** `isRetryableReason` for a raw failure, e.g. a `Schedule.while` input. */ +export const isRetryableError = (error: unknown): boolean => isRetryableReason(reasonOfUnknown(error)) - // Effect HttpClientError.RequestError with Transport reason - if ("_tag" in error && error._tag === "HttpClientError" && "reason" in error) { - return (error as { reason: string }).reason === "Transport" - } +// COPY - // Standard fetch errors - if (error instanceof TypeError && error.message.includes("fetch")) { - return true - } - - return false +/** What a toast or an inline error says. Never contains server or defect text. */ +export interface UserErrorMessage { + readonly title: string + readonly description?: string | undefined } -/** - * Checks if an error is a timeout error - */ -function isTimeoutError(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false - if ("_tag" in error) { - return (error as { _tag: string })._tag === "TimeoutError" - } - return false +const COMMON_MESSAGES: { readonly [R in CommonReason]: UserErrorMessage } = { + Offline: { title: "Connection lost", description: "Check your internet connection and try again." }, + Timeout: { title: "Request timed out", description: "The server is taking too long to respond. Please try again." }, + SignedOut: { title: "Your session has expired", description: "Please sign in again to continue." }, + RateLimited: { title: "Rate limit exceeded", description: "Please wait before trying again." }, + Unavailable: { title: "Service temporarily unavailable", description: "Please try again in a moment." }, + Unexpected: { title: "Something went wrong", description: "Please try again." }, } -/** - * Extracts user-friendly error information from a Cause. - * Uses type-safe Match for common errors, falls back to message extraction. - */ -export function getUserFriendlyError<E>(cause: Cause.Cause<E>): UserErrorMessage { - const firstFailure = cause.reasons.find(Cause.isFailReason)?.error - - if (firstFailure !== undefined) { - const error = firstFailure - - // Check for network errors first - if (isNetworkError(error)) { - return NETWORK_ERROR_MESSAGE - } - - // Check for timeout errors - if (isTimeoutError(error)) { - return TIMEOUT_ERROR_MESSAGE - } - - // Use type-safe matcher for common errors - if (isCommonAppError(error)) { - return getCommonErrorMessage(error) - } - - // Fall back to extracting message property for unknown tagged errors - if (typeof error === "object" && error !== null && "message" in error) { - return { - title: String((error as { message: unknown }).message), - isRetryable: false, - } - } +/** The shared copy for a common reason. */ +export const commonMessage = (reason: CommonReason): UserErrorMessage => COMMON_MESSAGES[reason] - if (error instanceof Error) { - return { title: error.message, isRetryable: false } - } +/** The generic message for a failure nothing classified. */ +export const DEFAULT_ERROR_MESSAGE: UserErrorMessage = COMMON_MESSAGES.Unexpected - if (typeof error === "string") { - return { title: error, isRetryable: false } - } - } - - // Check defects (unexpected errors) - const firstDefect = cause.reasons.find(Cause.isDieReason)?.defect - - if (firstDefect !== undefined) { - const defect = firstDefect - if (defect instanceof Error) { - return { title: defect.message, isRetryable: false } - } - } - - return DEFAULT_ERROR_MESSAGE -} +const isCommonReason = Schema.is(CommonReason) /** - * Gets just the user-friendly message string from a Cause. - * Convenience wrapper around getUserFriendlyError. - */ -export function getUserFriendlyMessage<E>(cause: Cause.Cause<E>): string { - return getUserFriendlyError(cause).title -} - -/** - * Determines if an error is retryable based on its type. - */ -export function isRetryableError<E>(cause: Cause.Cause<E>): boolean { - return getUserFriendlyError(cause).isRetryable -} + * The copy for a feature's failure reason: `specific` covers exactly the feature's own literals, the common + * reasons get the shared copy. + * + * @example failureMessage(reason, { NotFound: { title: "Channel not found" } }) + */ +export const failureMessage = <Reason extends string>( + reason: Reason | CommonReason, + specific: { readonly [R in Exclude<Reason, CommonReason>]: UserErrorMessage }, +): UserErrorMessage => + isCommonReason(reason) + ? commonMessage(reason) + : (specific as Record<string, UserErrorMessage>)[reason] ?? DEFAULT_ERROR_MESSAGE diff --git a/apps/web-foldkit/src/core/lib/notifications/README.md b/apps/web-foldkit/src/core/lib/notifications/README.md index 33dbdafa0..79e37d66a 100644 --- a/apps/web-foldkit/src/core/lib/notifications/README.md +++ b/apps/web-foldkit/src/core/lib/notifications/README.md @@ -7,11 +7,9 @@ This module centralizes desktop notification behavior for Hazel. - Every notification event is processed by the orchestrator exactly once per session unless a sink fails. - Policy decisions (muted/focus/session-start) are computed once in the orchestrator. - Sinks are execution-only and must not duplicate policy logic. -- Diagnostics are emitted for every processing attempt and are consumable by debug tooling. ## Main parts - `types.ts`: shared contracts for decisions, events, sink outcomes. - `orchestrator.ts`: queueing, dedupe, decisioning, sink fan-out. -- `sinks/`: concrete sound/native/in-app handlers. -- `diagnostics-store.ts`: in-memory diagnostics timeline for debug UI. +- `sinks/`: the sound and native handlers (in-app notifications are the synced rows themselves). diff --git a/apps/web-foldkit/src/core/lib/notifications/diagnostics-store.ts b/apps/web-foldkit/src/core/lib/notifications/diagnostics-store.ts deleted file mode 100644 index 9e8c3cf34..000000000 --- a/apps/web-foldkit/src/core/lib/notifications/diagnostics-store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NotificationProcessingRecord } from "./types" - -const MAX_RECORDS = 200 -const records: NotificationProcessingRecord[] = [] -const listeners = new Set<() => void>() - -const emit = () => { - for (const listener of listeners) { - listener() - } -} - -export const pushNotificationDiagnostics = (record: NotificationProcessingRecord) => { - records.unshift(record) - if (records.length > MAX_RECORDS) { - records.length = MAX_RECORDS - } - emit() -} - -export const getNotificationDiagnostics = () => records - -export const clearNotificationDiagnostics = () => { - records.length = 0 - emit() -} - -export const subscribeNotificationDiagnostics = (listener: () => void) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } -} diff --git a/apps/web-foldkit/src/core/lib/notifications/index.ts b/apps/web-foldkit/src/core/lib/notifications/index.ts index 2ddc8050f..cf7069c6a 100644 --- a/apps/web-foldkit/src/core/lib/notifications/index.ts +++ b/apps/web-foldkit/src/core/lib/notifications/index.ts @@ -1,10 +1,4 @@ -export { - clearNotificationDiagnostics, - getNotificationDiagnostics, - subscribeNotificationDiagnostics, -} from "./diagnostics-store" export { notificationOrchestrator, NotificationOrchestrator } from "./orchestrator" -export { inAppNotificationSink, InAppNotificationSink } from "./sinks/in-app" export { NativeNotificationSink } from "./sinks/native" export { SoundNotificationSink } from "./sinks/sound" export type { diff --git a/apps/web-foldkit/src/core/lib/notifications/orchestrator.test.ts b/apps/web-foldkit/src/core/lib/notifications/orchestrator.test.ts index d7331ea3c..a97d48499 100644 --- a/apps/web-foldkit/src/core/lib/notifications/orchestrator.test.ts +++ b/apps/web-foldkit/src/core/lib/notifications/orchestrator.test.ts @@ -1,13 +1,12 @@ -import { beforeEach, describe, expect, it } from "vitest" -import { clearNotificationDiagnostics, getNotificationDiagnostics } from "./diagnostics-store" +import { describe, expect, it } from "vitest" import { NotificationOrchestrator } from "./orchestrator" import type { NotificationDecision, NotificationEvent, NotificationSink } from "./types" class RecordingSink implements NotificationSink { public handled: Array<{ id: string; decision: NotificationDecision }> = [] - private readonly sink: "in-app" | "sound" | "native" + private readonly sink: "sound" | "native" - constructor(sink: "in-app" | "sound" | "native") { + constructor(sink: "sound" | "native") { this.sink = sink } @@ -45,12 +44,7 @@ const createEvent = (id: string, createdAt: string, channelId: string | null = n }) describe("NotificationOrchestrator", () => { - beforeEach(() => { - clearNotificationDiagnostics() - }) - it("processes events in chronological order", async () => { - const inApp = new RecordingSink("in-app") const sound = new RecordingSink("sound") const native = new RecordingSink("native") const orchestrator = new NotificationOrchestrator() @@ -64,7 +58,6 @@ describe("NotificationOrchestrator", () => { visibilityState: "hidden", now: Date.now(), }), - inAppSink: inApp, soundSink: sound, nativeSink: native, }) @@ -74,12 +67,11 @@ describe("NotificationOrchestrator", () => { createEvent("n1", "2025-01-01T10:00:01Z"), ]) - await waitFor(() => getNotificationDiagnostics().length === 2) - expect(inApp.handled.map((h) => h.id)).toEqual(["n1", "n2"]) + await waitFor(() => sound.handled.length === 2) + expect(sound.handled.map((h) => h.id)).toEqual(["n1", "n2"]) }) it("suppresses native notifications when focused", async () => { - const inApp = new RecordingSink("in-app") const sound = new RecordingSink("sound") const native = new RecordingSink("native") const orchestrator = new NotificationOrchestrator() @@ -93,21 +85,19 @@ describe("NotificationOrchestrator", () => { visibilityState: "visible", now: Date.now(), }), - inAppSink: inApp, soundSink: sound, nativeSink: native, }) orchestrator.enqueue([createEvent("n1", "2025-01-01T10:00:01Z", "channel-2")]) - await waitFor(() => getNotificationDiagnostics().length === 1) + await waitFor(() => native.handled.length === 1) - const record = getNotificationDiagnostics()[0] - expect(record?.decision.sendNative).toBe(false) - expect(record?.decision.playSound).toBe(true) + const decision = native.handled[0]?.decision + expect(decision?.sendNative).toBe(false) + expect(decision?.playSound).toBe(true) }) it("deduplicates repeated events", async () => { - const inApp = new RecordingSink("in-app") const sound = new RecordingSink("sound") const native = new RecordingSink("native") const orchestrator = new NotificationOrchestrator() @@ -121,7 +111,6 @@ describe("NotificationOrchestrator", () => { visibilityState: "hidden", now: Date.now(), }), - inAppSink: inApp, soundSink: sound, nativeSink: native, }) @@ -130,7 +119,9 @@ describe("NotificationOrchestrator", () => { orchestrator.enqueue([event]) orchestrator.enqueue([event]) - await waitFor(() => getNotificationDiagnostics().length === 1) - expect(inApp.handled.length).toBe(1) + await waitFor(() => sound.handled.length === 1) + // Let a duplicate run, if it were going to. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(sound.handled.length).toBe(1) }) }) diff --git a/apps/web-foldkit/src/core/lib/notifications/orchestrator.ts b/apps/web-foldkit/src/core/lib/notifications/orchestrator.ts index d68cf6868..80e73becc 100644 --- a/apps/web-foldkit/src/core/lib/notifications/orchestrator.ts +++ b/apps/web-foldkit/src/core/lib/notifications/orchestrator.ts @@ -1,5 +1,4 @@ import { toEpochMs } from "~/lib/utils" -import { pushNotificationDiagnostics } from "./diagnostics-store" import type { NotificationDecision, NotificationDecisionContext, @@ -10,7 +9,6 @@ import type { export interface NotificationOrchestratorDependencies { getContext: () => NotificationDecisionContext - inAppSink: NotificationSink soundSink: NotificationSink nativeSink: NotificationSink } @@ -50,7 +48,8 @@ export class NotificationOrchestrator { } private async processQueue() { - if (!this.deps || this.processing) return + const deps = this.deps + if (!deps || this.processing) return this.processing = true try { @@ -60,8 +59,7 @@ export class NotificationOrchestrator { continue } - const record = await this.processEvent(event) - pushNotificationDiagnostics(record) + const record = await this.processEvent(deps, event) const hasFailures = record.results.some((result) => result.status === "failed") if (!hasFailures) { @@ -77,18 +75,16 @@ export class NotificationOrchestrator { } } - private async processEvent(event: NotificationEvent): Promise<NotificationProcessingRecord> { - if (!this.deps) { - throw new Error("NotificationOrchestrator dependencies are not configured") - } - + private async processEvent( + deps: NotificationOrchestratorDependencies, + event: NotificationEvent, + ): Promise<NotificationProcessingRecord> { const startedAt = Date.now() - const decision = this.makeDecision(event, this.deps.getContext()) + const decision = this.makeDecision(event, deps.getContext()) const results = await Promise.all([ - this.deps.inAppSink.handle(event, decision), - this.deps.soundSink.handle(event, decision), - this.deps.nativeSink.handle(event, decision), + deps.soundSink.handle(event, decision), + deps.nativeSink.handle(event, decision), ]) const finishedAt = Date.now() diff --git a/apps/web-foldkit/src/core/lib/notifications/sinks/in-app.ts b/apps/web-foldkit/src/core/lib/notifications/sinks/in-app.ts deleted file mode 100644 index d55db5d75..000000000 --- a/apps/web-foldkit/src/core/lib/notifications/sinks/in-app.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - NotificationDecision, - NotificationEvent, - NotificationSink, - NotificationSinkResult, -} from "../types" - -/** - * In-app notifications are backed by synced DB state; this sink intentionally - * acts as a no-op marker so the orchestrator still tracks it uniformly. - */ -export class InAppNotificationSink implements NotificationSink { - async handle( - _event: NotificationEvent, - _decision: NotificationDecision, - ): Promise<NotificationSinkResult> { - return { - sink: "in-app", - status: "sent", - reason: "ok", - } - } -} - -export const inAppNotificationSink = new InAppNotificationSink() diff --git a/apps/web-foldkit/src/core/lib/notifications/types.ts b/apps/web-foldkit/src/core/lib/notifications/types.ts index 220930259..cbc081a92 100644 --- a/apps/web-foldkit/src/core/lib/notifications/types.ts +++ b/apps/web-foldkit/src/core/lib/notifications/types.ts @@ -13,7 +13,7 @@ export type NotificationDeliveryReason = | "api_unavailable" | "error" -export type NotificationSinkName = "in-app" | "sound" | "native" +export type NotificationSinkName = "sound" | "native" export type NotificationSinkStatus = "sent" | "suppressed" | "failed" diff --git a/apps/web-foldkit/src/core/lib/platform-storage/index.ts b/apps/web-foldkit/src/core/lib/platform-storage/index.ts index 82e8b6460..888582807 100644 --- a/apps/web-foldkit/src/core/lib/platform-storage/index.ts +++ b/apps/web-foldkit/src/core/lib/platform-storage/index.ts @@ -1,6 +1,6 @@ /** * @module PlatformStorage - * @description Platform-aware storage utilities for Effect atoms + * @description Platform-aware key-value storage * * Provides a unified KeyValueStore abstraction that works across: * - Browser: localStorage @@ -8,5 +8,4 @@ */ export { layer } from "./platform-key-value-store" -export { platformStorageRuntime } from "./platform-runtime" export { layerTauriStore } from "./tauri-key-value-store" diff --git a/apps/web-foldkit/src/core/lib/platform-storage/platform-runtime.ts b/apps/web-foldkit/src/core/lib/platform-storage/platform-runtime.ts deleted file mode 100644 index ab32232da..000000000 --- a/apps/web-foldkit/src/core/lib/platform-storage/platform-runtime.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @module PlatformRuntime - * @description Shared Atom runtime for platform-aware key-value storage - * - * USAGE: Replace `BrowserKeyValueStore.layerLocalStorage` imports with this runtime - * - * Uses: - * - Tauri desktop: @tauri-apps/plugin-store (settings.json) - * - Browser: localStorage - * - * @example - * // Before - * import { BrowserKeyValueStore } from "@effect/platform-browser" - * const localStorageRuntime = Atom.runtime(BrowserKeyValueStore.layerLocalStorage) - * - * // After - * import { platformStorageRuntime } from "~/lib/platform-storage" - * // Then use platformStorageRuntime in Atom.kvs - */ - -import { Atom } from "effect/reactivity" -import { layer } from "./platform-key-value-store" - -export const platformStorageRuntime = Atom.runtime(layer) as Atom.AtomRuntime<any, any> diff --git a/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts b/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts index 6baeab112..ab0443a4b 100644 --- a/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts +++ b/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts @@ -8,92 +8,55 @@ */ import * as KeyValueStore from "effect/persistence/KeyValueStore" -import { KeyValueStoreError as SystemError } from "effect/persistence/KeyValueStore" +import { KeyValueStoreError } from "effect/persistence/KeyValueStore" import { getTauriStore, type TauriStoreApi } from "@hazel/desktop/bridge" import { Effect, Layer } from "effect" const STORE_NAME = "settings.json" -type StoreType = Awaited<ReturnType<TauriStoreApi["load"]>> - -// Load store effect - separate from caching -const loadStore: Effect.Effect<StoreType, SystemError> = Effect.tryPromise({ - try: async () => { - const store = getTauriStore() - if (!store) throw new Error("Tauri store not available") - return store.load(STORE_NAME, { autoSave: true, defaults: {} }) - }, - catch: (error) => - new SystemError({ - message: `Failed to load Tauri store: ${error}`, - method: "getStore", - key: STORE_NAME, - cause: error, - }), -}) - -// Effect.cached memoizes the effect - only runs once, result is cached -const getStore = Effect.cached(loadStore) - -const makeError = (method: string, key: string, error: unknown) => - new SystemError({ - message: `Tauri store ${method} failed: ${error}`, - method, - key, - cause: error, +type Store = Awaited<ReturnType<TauriStoreApi["load"]>> + +const storeError = (method: string, key: string, cause: unknown) => + new KeyValueStoreError({ message: `Tauri store ${method} failed`, method, key, cause }) + +const loadStore: Effect.Effect<Store, KeyValueStoreError> = Effect.suspend(() => { + const tauriStore = getTauriStore() + if (!tauriStore) { + return Effect.fail( + new KeyValueStoreError({ message: "Tauri store not available", method: "load", key: STORE_NAME }), + ) + } + return Effect.tryPromise({ + try: () => tauriStore.load(STORE_NAME, { autoSave: true, defaults: {} }), + catch: (cause) => storeError("load", STORE_NAME, cause), }) +}) /** - * Creates a KeyValueStore layer backed by Tauri's plugin-store + * A KeyValueStore layer backed by Tauri's plugin-store. The store loads on first use, so a store that fails to + * load fails that read or write (a typed `KeyValueStoreError` callers recover from), not the whole layer. */ export const layerTauriStore: Layer.Layer<KeyValueStore.KeyValueStore> = Layer.effect( KeyValueStore.KeyValueStore, Effect.gen(function* () { - // Effect.cached returns Effect<Effect<A, E>>, yield twice to get the cached value - const store = yield* (yield* getStore).pipe(Effect.orDie) + const getStore = yield* Effect.cached(loadStore) + const withStore = <A>(method: string, key: string, run: (store: Store) => Promise<A>) => + getStore.pipe( + Effect.flatMap((store) => + Effect.tryPromise({ try: () => run(store), catch: (cause) => storeError(method, key, cause) }), + ), + ) return KeyValueStore.makeStringOnly({ - get: (key: string) => - Effect.tryPromise({ - try: async () => await store.get<string>(key), - catch: (error) => makeError("get", key, error), - }), - - set: (key: string, value: string) => - Effect.tryPromise({ - try: async () => { - await store.set(key, value) - }, - catch: (error) => makeError("set", key, error), - }), - - remove: (key: string) => - Effect.tryPromise({ - try: async () => { - await store.delete(key) - }, - catch: (error) => makeError("remove", key, error), - }), - - clear: Effect.tryPromise({ - try: async () => { - await store.clear() - }, - catch: (error) => makeError("clear", "all", error), - }), - - size: Effect.tryPromise({ - try: async () => store.length(), - catch: (error) => makeError("size", "length", error), - }), - - has: (key: string) => - Effect.tryPromise({ - try: async () => { - const value = await store.get<string>(key) - return value !== null && value !== undefined - }, - catch: (error) => makeError("has", key, error), + get: (key) => withStore("get", key, (store) => store.get<string>(key)), + set: (key, value) => withStore("set", key, (store) => store.set(key, value)), + remove: (key) => withStore("remove", key, (store) => store.delete(key)).pipe(Effect.asVoid), + clear: withStore("clear", "all", (store) => store.clear()), + size: withStore("size", "length", (store) => store.length()), + has: (key) => + withStore("has", key, async (store) => { + const value = await store.get<string>(key) + return value !== null && value !== undefined }), }) }), diff --git a/apps/web-foldkit/src/core/lib/services/common/api-client.ts b/apps/web-foldkit/src/core/lib/services/common/api-client.ts index 3c9f40685..05a09ca06 100644 --- a/apps/web-foldkit/src/core/lib/services/common/api-client.ts +++ b/apps/web-foldkit/src/core/lib/services/common/api-client.ts @@ -1,40 +1,8 @@ -/** - * @module API client with platform-aware authentication - * @platform shared (with platform-specific sections) - * @description HTTP client that uses Bearer tokens for desktop and cookies for web - */ - -import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http" -import { HttpApiClient } from "effect/http-api" -import { HazelApi } from "@hazel/domain/http" -import { Context, Layer } from "effect" -import * as Effect from "effect/Effect" +import { Layer } from "effect" +import { FetchHttpClient } from "effect/http" import { authenticatedFetch } from "../../auth-fetch" +/** The HTTP client `HazelApiClient` (`src/rpc.ts`) uses: fetch with the session's bearer token. */ export const CustomFetchLive = FetchHttpClient.layer.pipe( Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, authenticatedFetch)), ) - -export class ApiClient extends Context.Service<ApiClient>()("ApiClient", { - make: Effect.gen(function* () { - return yield* HttpApiClient.make(HazelApi, { - baseUrl: import.meta.env.VITE_BACKEND_URL, - transformClient: (client) => - client.pipe( - HttpClient.retry({ - times: 3, - // Only retry server errors (5xx), not client errors (4xx) like 401/403 - while: (error) => { - if (HttpClientError.isHttpClientError(error)) { - const status = error.response?.status - return status === undefined || (status >= 500 && status < 600) - } - return false - }, - }), - ), - }) - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(CustomFetchLive)) -} diff --git a/apps/web-foldkit/src/core/lib/services/common/atom-client.ts b/apps/web-foldkit/src/core/lib/services/common/atom-client.ts deleted file mode 100644 index 5604a81bc..000000000 --- a/apps/web-foldkit/src/core/lib/services/common/atom-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AtomHttpApi } from "effect/reactivity" -import { HazelApi } from "@hazel/domain/http" -import { CustomFetchLive } from "./api-client" - -export class HazelApiClient extends AtomHttpApi.Service<HazelApiClient>()("HazelApiClient", { - api: HazelApi, - httpClient: CustomFetchLive, - baseUrl: import.meta.env.VITE_BACKEND_URL || "http://localhost:3003", -}) {} diff --git a/apps/web-foldkit/src/core/lib/services/common/rpc-atom-client.ts b/apps/web-foldkit/src/core/lib/services/common/rpc-atom-client.ts deleted file mode 100644 index 66abde87a..000000000 --- a/apps/web-foldkit/src/core/lib/services/common/rpc-atom-client.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Reactivity } from "effect/reactivity" -import { FetchHttpClient } from "effect/http" -import { RpcClient as RpcClientBuilder, RpcSerialization } from "effect/rpc" -import { AtomRpc } from "effect/reactivity" -import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-middleware" -import { - AttachmentRpcs, - BotRpcs, - ChannelMemberRpcs, - ChannelRpcs, - ChannelSectionRpcs, - ChannelWebhookRpcs, - ChatSyncRpcs, - ConnectShareRpcs, - CustomEmojiRpcs, - GitHubSubscriptionRpcs, - IntegrationRequestRpcs, - RssSubscriptionRpcs, - MessageReactionRpcs, - MessageRpcs, - NotificationRpcs, - OrganizationMemberRpcs, - OrganizationRpcs, - PinnedMessageRpcs, - TypingIndicatorRpcs, - UserPresenceStatusRpcs, - UserRpcs, -} from "@hazel/domain/rpc" -import { Layer } from "effect" - -const backendUrl = import.meta.env.VITE_BACKEND_URL -const httpUrl = `${backendUrl}/rpc` - -const BaseProtocolLive = RpcClientBuilder.layerProtocolHttp({ - url: httpUrl, -}).pipe( - Layer.provide(FetchHttpClient.layer.pipe(Layer.provide(RpcCredentialsLive))), - Layer.provide(RpcSerialization.layerNdjson), -) - -export const RpcProtocolLive = BaseProtocolLive - -// Build the protocol layer with middleware -// Use Layer.mergeAll to make AuthMiddlewareClientLive available alongside the protocol -const AtomRpcProtocolLive = Layer.mergeAll(RpcProtocolLive, AuthMiddlewareClientLive, Reactivity.layer) - -const BaseRpcs = MessageRpcs.merge( - NotificationRpcs, - IntegrationRequestRpcs, - ChannelRpcs, - ChannelMemberRpcs, - ChannelSectionRpcs, - ChannelWebhookRpcs, - CustomEmojiRpcs, - GitHubSubscriptionRpcs, - RssSubscriptionRpcs, - OrganizationRpcs, - OrganizationMemberRpcs, - UserRpcs, - MessageReactionRpcs, - TypingIndicatorRpcs, - PinnedMessageRpcs, - AttachmentRpcs, - UserPresenceStatusRpcs, - BotRpcs, - ConnectShareRpcs, -) - -const AllRpcs = BaseRpcs.merge(ChatSyncRpcs) -export class HazelRpcClient extends AtomRpc.Service<HazelRpcClient>()("HazelRpcClient", { - group: AllRpcs, - protocol: AtomRpcProtocolLive, -}) { - static readonly layer = Layer.effect( - HazelRpcClient, - RpcClientBuilder.make(AllRpcs, { flatten: true }), - ).pipe(Layer.provide(AtomRpcProtocolLive)) -} - -export type { RpcClientError } from "effect/rpc" diff --git a/apps/web-foldkit/src/core/lib/services/common/runtime.ts b/apps/web-foldkit/src/core/lib/services/common/runtime.ts deleted file mode 100644 index 818da3f33..000000000 --- a/apps/web-foldkit/src/core/lib/services/common/runtime.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Layer, ManagedRuntime } from "effect" -import { ApiClient } from "./api-client" -import { HazelRpcClient } from "./rpc-atom-client" -import { TracerLive } from "./telemetry" - -/** - * Shared layer containing all services - * - * This layer includes: - * - ApiClient: HTTP client for REST API calls - * - RpcClient: RPC client for collection mutations - * - HazelRpcClient: RPC client for atom mutations - * - TracerLive: OpenTelemetry tracing (DevTools in dev, Maple in production) - * - * All RPC clients share the same WebSocket connection via RpcProtocolLive. - */ -export const runtimeLayer = Layer.mergeAll(ApiClient.layer, HazelRpcClient.layer, TracerLive) - -/** - * MemoMap shared between the ManagedRuntime and the atom runtime factory. - */ -export const sharedMemoMap = Layer.makeMemoMapUnsafe() - -/** - * Managed runtime for imperative Effect execution - * - * Uses sharedMemoMap to ensure layer memoization is shared with - * atom runtimes created via `atomRuntime` (see registry.ts). This prevents duplicate WebSocket connections by - * ensuring both the ManagedRuntime (for collections) and AtomRuntime (for - * mutations) build layers with the same MemoMap, allowing Effect to reuse - * already-built layer instances. - * - * Used by collections.ts and other imperative code that calls runtime.runPromise(). - */ -export const runtime = ManagedRuntime.make(runtimeLayer, { memoMap: sharedMemoMap }) diff --git a/apps/web-foldkit/src/core/lib/services/common/telemetry.ts b/apps/web-foldkit/src/core/lib/services/common/telemetry.ts deleted file mode 100644 index e9e89559b..000000000 --- a/apps/web-foldkit/src/core/lib/services/common/telemetry.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Layer } from "effect" - -/** - * Browser telemetry layer. - * - * Production tracing is disabled — the backend already traces all API/RPC - * calls, so frontend spans are redundant noise that generates excessive - * OTLP export volume per browser tab. - */ -export const TracerLive = Layer.empty diff --git a/apps/web-foldkit/src/data/actions.test.ts b/apps/web-foldkit/src/data/actions.test.ts index d81e5dc7b..af918575a 100644 --- a/apps/web-foldkit/src/data/actions.test.ts +++ b/apps/web-foldkit/src/data/actions.test.ts @@ -1,41 +1,86 @@ -import { Cause, Effect, Exit, Schema } from "effect" -import { Atom } from "effect/reactivity" -import { UnauthorizedError } from "@hazel/domain/errors" +import { InternalServerError, UnauthorizedError } from "@hazel/domain/errors" +import { Cause, Effect, Exit, Fiber, Schema } from "effect" +import { HttpClientError, HttpClientRequest } from "effect/http" +import { RpcClientError } from "effect/rpc" import { describe, expect, test } from "vitest" -import { errorToast, failureToast, runAtomFn, settle, successToast } from "./actions" +import { commonMessage, isRetryableError } from "~/lib/error-messages" +import { catchCommon, errorToast, failureToast, settle, successToast } from "./actions" -/** The shared runner for legacy optimistic actions and its `exitToast`-style failure toasts. */ +/** The Command side of the error convention, and the legacy `exitToast`-style failure toasts. */ class ChannelNotFoundError extends Schema.TaggedError<ChannelNotFoundError>()("ChannelNotFoundError", { message: Schema.String, }) {} -const rename = Atom.fn((name: string) => - name === "" - ? Effect.fail(new ChannelNotFoundError({ message: "gone" })) - : Effect.succeed(name.toUpperCase()), -) +const request = HttpClientRequest.get("https://api.hazel.test/rpc") +const offline = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description: "Failed to fetch" }), +}) +const rpcOffline = new RpcClientError.RpcClientError({ + reason: new HttpClientError.HttpClientErrorSchema({ _tag: "HttpError", kind: "TransportError" }), +}) +const unauthorized = new UnauthorizedError({ message: "no", detail: "no" }) +const serverError = new InternalServerError({ message: "pg: relation users does not exist" }) + +const Failed = (reason: string) => ({ _tag: "Failed" as const, reason }) -describe("runAtomFn", () => { - test("sets the action and returns its settled value", async () => { - expect(await Effect.runPromise(runAtomFn(rename, "general"))).toBe("GENERAL") +describe("catchCommon", () => { + test("a common error becomes its reason", async () => { + expect(await Effect.runPromise(Effect.fail(offline).pipe(catchCommon(Failed)))).toEqual(Failed("Offline")) + expect(await Effect.runPromise(Effect.fail(rpcOffline).pipe(catchCommon(Failed)))).toEqual(Failed("Offline")) + expect(await Effect.runPromise(Effect.fail(unauthorized).pipe(catchCommon(Failed)))).toEqual( + Failed("SignedOut"), + ) + expect(await Effect.runPromise(Effect.fail(serverError).pipe(catchCommon(Failed)))).toEqual( + Failed("Unexpected"), + ) }) - test("keeps the action's typed failure", async () => { - const exit = await Effect.runPromise(Effect.exit(runAtomFn(rename, ""))) - expect(Exit.isFailure(exit) && Cause.findErrorOption(exit.cause)).toMatchObject({ - value: { _tag: "ChannelNotFoundError" }, - }) + test("feature errors are caught first with catchTags", async () => { + const result = await Effect.runPromise( + Effect.fail(new ChannelNotFoundError({ message: "gone" })).pipe( + Effect.catchTags({ ChannelNotFoundError: () => Effect.succeed(Failed("NotFound")) }), + catchCommon(Failed), + ), + ) + expect(result).toEqual(Failed("NotFound")) + }) + + test("a defect becomes Unexpected instead of crashing the app", async () => { + expect(await Effect.runPromise(Effect.die(new Error("boom")).pipe(catchCommon(Failed)))).toEqual( + Failed("Unexpected"), + ) + }) + + test("an interrupt stays an interrupt", async () => { + const exit = await Effect.runPromise( + Effect.never.pipe( + catchCommon(Failed), + Effect.forkChild, + Effect.flatMap((fiber) => Fiber.interrupt(fiber).pipe(Effect.andThen(Fiber.await(fiber)))), + ), + ) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + }) +}) + +describe("retry policy", () => { + test("network drops are retryable, server and auth failures are not", () => { + expect(isRetryableError(offline)).toBe(true) + expect(isRetryableError(rpcOffline)).toBe(true) + expect(isRetryableError(unauthorized)).toBe(false) + expect(isRetryableError(serverError)).toBe(false) + expect(isRetryableError(new Error("boom"))).toBe(false) }) }) describe("failure toasts", () => { const unhandled = Cause.fail(new ChannelNotFoundError({ message: "gone" })) - test("a tag handler wins over either fallback", async () => { + test("a tag handler wins", async () => { const toast = await Effect.runPromise( settle( - runAtomFn(rename, ""), + Effect.fail(new ChannelNotFoundError({ message: "gone" })), () => successToast("Renamed"), (cause) => failureToast(cause, "friendly", { @@ -46,35 +91,31 @@ describe("failure toasts", () => { expect(toast).toEqual(errorToast("Channel not found")) }) - test("a handler function builds the message from the tagged error", () => { - const handlers = { - ChannelNotFoundError: (error: { readonly _tag: string }) => ({ title: error._tag }), - } - expect(failureToast(unhandled, "exitToast", handlers)).toEqual(errorToast("ChannelNotFoundError")) - }) - - test("the friendly fallback reads the error's message", () => { - expect(failureToast(unhandled, "friendly")).toEqual(errorToast("gone")) + test("a handler function builds the message from the typed error", () => { + expect(failureToast(unhandled, "exitToast", { ChannelNotFoundError: (error) => ({ title: error._tag }) })).toEqual( + errorToast("ChannelNotFoundError"), + ) }) - test("the exitToast fallback shows the generic message for an unknown tag", () => { - expect(failureToast(unhandled, "exitToast")).toEqual(errorToast("An error occurred")) + test("an unknown error never shows its own message", () => { + const generic = errorToast(commonMessage("Unexpected").title, commonMessage("Unexpected").description) + expect(failureToast(unhandled, "friendly")).toEqual(generic) + expect(failureToast(Cause.fail(serverError), "exitToast")).toEqual(generic) }) - test("both fallbacks use the common message for a common error", () => { - const cause = Cause.fail(new UnauthorizedError({ message: "no", detail: "no" })) - const common = errorToast( - "You don't have permission to do this", - "Contact your admin if you need access.", + test("a common error gets the shared copy", () => { + const copy = commonMessage("SignedOut") + expect(failureToast(Cause.fail(unauthorized), "friendly")).toEqual(errorToast(copy.title, copy.description)) + const offlineCopy = commonMessage("Offline") + expect(failureToast(Cause.fail(offline), "exitToast")).toEqual( + errorToast(offlineCopy.title, offlineCopy.description), ) - expect(failureToast(cause, "friendly")).toEqual(common) - expect(failureToast(cause, "exitToast")).toEqual(common) }) - test("a defect shows its message under either fallback", () => { - const cause = Cause.die(new Error("boom")) - expect(failureToast(cause, "friendly")).toEqual(errorToast("boom")) - expect(failureToast(cause, "exitToast")).toEqual(errorToast("boom")) + test("a defect never shows its message", () => { + const toast = failureToast(Cause.die(new Error("boom")), "friendly") + expect(toast.title).not.toContain("boom") + expect(toast).toEqual(errorToast(commonMessage("Unexpected").title, commonMessage("Unexpected").description)) }) test("success and error toasts default to no description", () => { diff --git a/apps/web-foldkit/src/data/actions.ts b/apps/web-foldkit/src/data/actions.ts index 1bb4d6d80..cfbb9378d 100644 --- a/apps/web-foldkit/src/data/actions.ts +++ b/apps/web-foldkit/src/data/actions.ts @@ -1,53 +1,39 @@ import { Cause, Effect, Option } from "effect" -import { type AsyncResult, type Atom, AtomRegistry } from "effect/reactivity" import { + type CommonError, + type CommonReason, + commonMessage, + commonReasonOf, DEFAULT_ERROR_MESSAGE, - getCommonErrorMessage, - getUserFriendlyError, - isCommonAppError, + isCommonError, + type UserErrorMessage, } from "~/lib/error-messages" import type { ToastRequest } from "../overlay/toasts" /** - * Mutations through the legacy optimistic actions (`db/actions.ts`), and the app's one set of toast - * helpers (legacy `exitToast` as data). - * One atom registry for the whole app, so concurrent actions share state like legacy's one registry. + * The Command side of the error convention (`docs/foldkit-decisions/s6-errors.md`), and the app's one set of + * toast helpers. */ -const registry = AtomRegistry.make() - -/** - * Runs a legacy `Atom.fn` action (the optimistic actions in `db/actions`) like - * `useAtomSet(action, { mode: "promiseExit" })`: set the variables, wait for the settled result. - */ -export const runAtomFn = <A, E, V>( - atom: Atom.Writable<AsyncResult.AsyncResult<A, E>, V>, - variables: V, -): Effect.Effect<A, E> => - Effect.scoped( - Effect.gen(function* () { - yield* AtomRegistry.mount(registry, atom) - registry.set(atom, variables) - return yield* AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }) - }), - ) - -/** A handler's message, legacy `UserErrorMessage`. `isRetryable` is kept for parity, no retry action yet. */ -export interface ErrorMessage { - readonly title: string - readonly description?: string | null | undefined - readonly isRetryable?: boolean -} - -/** Legacy `onErrorTag(tag, handler)`: a fixed message or one built from the tagged error. */ -export type ErrorHandler = ErrorMessage | ((error: { readonly _tag: string }) => ErrorMessage) -export type ErrorHandlers = Readonly<Record<string, ErrorHandler>> - /** - * The message for a failure no handler matched. `exitToast`: legacy `exitToast` (the common-error map, - * else "An error occurred"). `friendly`: `getUserFriendlyError` (adds network, timeout, `error.message`). + * The one defect boundary for a Command. Run it after `Effect.catchTags` has handled the feature's own errors: + * every typed error left must be a `CommonError` and becomes its reason; a defect is logged and becomes + * `"Unexpected"` (the runtime would otherwise crash the app). Interrupts stay interrupts. */ -export type Fallback = "exitToast" | "friendly" +export const catchCommon = + <M>(onReason: (reason: CommonReason) => M) => + <A, E extends CommonError, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A | M, never, R> => + Effect.catchCause(effect, (cause) => + Option.match(Cause.findErrorOption(cause), { + onSome: (error) => Effect.succeed(onReason(commonReasonOf(error))), + onNone: () => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logError("Command failed unexpectedly", cause).pipe( + Effect.as(onReason("Unexpected")), + ), + }), + ) export const successToast = (title: string, description: string | null = null): ToastRequest => ({ intent: "success", @@ -76,32 +62,61 @@ export const infoToast = (title: string, description: string | null = null): Toa /** Give it an `id` and send the result toast with the same id to replace it. */ export const loadingToast = (title: string): ToastRequest => ({ intent: "loading", title, description: null }) -const hasTag = (error: unknown): error is { readonly _tag: string } => - typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string" +/** A failure's copy as an error toast. */ +export const errorToastOf = (message: UserErrorMessage): ToastRequest => + errorToast(message.title, message.description ?? null) + +/** The shared copy for a common reason, as an error toast. */ +export const reasonToast = (reason: CommonReason): ToastRequest => errorToastOf(commonMessage(reason)) -const unhandledMessage = (error: unknown, cause: Cause.Cause<unknown>, fallback: Fallback): ErrorMessage => { - if (fallback === "friendly") return getUserFriendlyError(cause) - return isCommonAppError(error) ? getCommonErrorMessage(error) : DEFAULT_ERROR_MESSAGE +// LEGACY: `failureToast` and friends, for screens not yet on `Failed*({ reason })`. + +/** A legacy handler's message. `isRetryable` is ignored: retry policy is `isRetryableReason`. */ +export interface ErrorMessage { + readonly title: string + readonly description?: string | null | undefined + readonly isRetryable?: boolean +} + +type Tagged<E> = Extract<E, { readonly _tag: string }> + +type ErrorHandler<E> = ErrorMessage | ((error: E) => ErrorMessage) + +/** A fixed message or one built from the tagged error, keyed by the failure's own tags. */ +export type ErrorHandlers<E> = { + readonly [K in Tagged<E>["_tag"]]?: ErrorHandler<Extract<Tagged<E>, { readonly _tag: K }>> } -const messageOf = (cause: Cause.Cause<unknown>, handlers: ErrorHandlers, fallback: Fallback): ErrorMessage => +/** Legacy `exitToast` vs `getUserFriendlyError` fallbacks; both now resolve the same safe way. */ +export type Fallback = "exitToast" | "friendly" + +const hasTag = (error: unknown): error is { readonly _tag: string } => + typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string" + +const messageOf = <E>(cause: Cause.Cause<E>, handlers: ErrorHandlers<E>): ErrorMessage => Option.match(Cause.findErrorOption(cause), { - onNone: () => getUserFriendlyError(cause), + onNone: () => DEFAULT_ERROR_MESSAGE, onSome: (error) => { - if (!hasTag(error)) return unhandledMessage(error, cause, fallback) - const handler = handlers[error._tag] - if (handler === undefined) return unhandledMessage(error, cause, fallback) - return typeof handler === "function" ? handler(error) : handler + if (hasTag(error)) { + const handler = (handlers as Record<string, ErrorHandler<unknown> | undefined>)[error._tag] + if (handler !== undefined) return typeof handler === "function" ? handler(error) : handler + } + return isCommonError(error) ? commonMessage(commonReasonOf(error)) : DEFAULT_ERROR_MESSAGE }, }) -/** Legacy `exitToast(exit).onErrorTag(...)` error branch, with the page's explicit fallback. */ -export const failureToast = ( - cause: Cause.Cause<unknown>, - fallback: Fallback, - handlers: ErrorHandlers = {}, +/** + * Legacy `exitToast(exit).onErrorTag(...)` error branch: a handler for the failure's tag, else the common + * copy, else the generic message. Never shows error or defect text; unclassified failures and defects are + * logged instead. + */ +export const failureToast = <E>( + cause: Cause.Cause<E>, + _fallback: Fallback, + handlers: NoInfer<ErrorHandlers<E>> = {}, ): ToastRequest => { - const message = messageOf(cause, handlers, fallback) + const message = messageOf(cause, handlers) + if (message === DEFAULT_ERROR_MESSAGE) console.error("[failureToast] unclassified failure", cause) return errorToast(message.title, message.description ?? null) } @@ -109,7 +124,7 @@ export const failureToast = ( export const toastOfExit = <A, E, R>( effect: Effect.Effect<A, E, R>, fallback: Fallback, - options: { readonly success?: string; readonly handlers?: ErrorHandlers } = {}, + options: { readonly success?: string; readonly handlers?: NoInfer<ErrorHandlers<E>> } = {}, ): Effect.Effect<ToastRequest | null, never, R> => Effect.matchCause(effect, { onSuccess: () => (options.success === undefined ? null : successToast(options.success)), @@ -122,7 +137,7 @@ export const rateLimitMessage: ErrorMessage = { description: "Please wait before trying again.", } -/** Settles an Effect into one of two Messages, keeping the failure's toast. */ +/** Settles an Effect into one of two Messages, keeping the failure's cause. */ export const settle = <A, E, R, Success, Failure>( effect: Effect.Effect<A, E, R>, onSuccess: (value: A) => Success, diff --git a/apps/web-foldkit/src/data/storage.ts b/apps/web-foldkit/src/data/storage.ts index f77375fc6..537e630a8 100644 --- a/apps/web-foldkit/src/data/storage.ts +++ b/apps/web-foldkit/src/data/storage.ts @@ -3,11 +3,11 @@ import * as KeyValueStore from "effect/persistence/KeyValueStore" import { layer as platformStorageLayer } from "~/lib/platform-storage/platform-key-value-store" /** - * The legacy `Atom.kvs` persistence (localStorage on the web, the Tauri store on desktop), read and - * written with the same key-value layer and schema store, so both apps share one stored value. + * Persisted settings (localStorage on the web, the Tauri store on desktop), with the keys and schema store + * encoding the legacy app used, so values saved before the Foldkit app still load. */ -/** The stored value, or `None` when it is missing or undecodable (the atom then uses its default). */ +/** The stored value, or `None` when it is missing or undecodable (callers then use their default). */ export const readStored = <S extends Schema.Constraint>(key: string, schema: S) => KeyValueStore.KeyValueStore.use((store) => KeyValueStore.toSchemaStore(store, schema).get(key)).pipe( Effect.provide(platformStorageLayer), diff --git a/apps/web-foldkit/src/emoji-picker/usage.ts b/apps/web-foldkit/src/emoji-picker/usage.ts deleted file mode 100644 index 56a892121..000000000 --- a/apps/web-foldkit/src/emoji-picker/usage.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Atom, AtomRegistry } from "effect/reactivity" -import type { EmojiUsage } from "~/atoms/emoji-atoms" - -/** `useEmojiStats`: the persisted usage counts behind `topEmojisAtom`, written outside React. */ - -const registry = AtomRegistry.make() - -export const trackEmojiUsage = (atom: Atom.Writable<EmojiUsage>, emoji: string): void => { - const release = registry.mount(atom) - registry.update(atom, (previous) => ({ ...previous, [emoji]: (previous[emoji] ?? 0) + 1 })) - release() -} diff --git a/apps/web-foldkit/src/gif-picker/picker.ts b/apps/web-foldkit/src/gif-picker/picker.ts index 2be31136a..ba60dda59 100644 --- a/apps/web-foldkit/src/gif-picker/picker.ts +++ b/apps/web-foldkit/src/gif-picker/picker.ts @@ -2,8 +2,7 @@ import type { KlipyGif } from "@hazel/domain/http" import { Duration, Effect, Schema } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" -import { HazelApiClient } from "~/lib/services/common/atom-client" -import { runAtomFn } from "../data/actions" +import { HazelApiClient } from "../rpc" import { PopoverEvent } from "../picker-popover/popover" /** @@ -97,10 +96,6 @@ export type OutMessage = typeof OutMessage.Type // COMMAND -const trending = HazelApiClient.mutation("klipy", "trending") -const search = HazelApiClient.mutation("klipy", "search") -const categories = HazelApiClient.mutation("klipy", "categories") - const toGif = (gif: KlipyGif): Gif => ({ slug: gif.slug, title: gif.title, @@ -117,8 +112,8 @@ export const FetchGifs = Command.define("FetchGifs", { messages: [Message.SucceededFetchGifs, Message.FailedFetchGifs], execute: ({ query, page, append, requestId }) => (query - ? runAtomFn(search, { query: { q: query, page, per_page: PER_PAGE } }) - : runAtomFn(trending, { query: { page, per_page: PER_PAGE } }) + ? HazelApiClient.use((client) => client.klipy.search({ query: { q: query, page, per_page: PER_PAGE } })) + : HazelApiClient.use((client) => client.klipy.trending({ query: { page, per_page: PER_PAGE } })) ).pipe( Effect.map((response) => Message.SucceededFetchGifs({ @@ -135,7 +130,7 @@ export const FetchGifs = Command.define("FetchGifs", { export const FetchCategories = Command.define("FetchCategories", { messages: [Message.SucceededFetchCategories, Message.FailedFetchCategories], - execute: runAtomFn(categories, {}).pipe( + execute: HazelApiClient.use((client) => client.klipy.categories()).pipe( Effect.map((response) => Message.SucceededFetchCategories({ categories: response.categories.map(({ category, query }) => ({ category, query })), @@ -155,7 +150,7 @@ export const WaitForGifSearch = Command.define("WaitForGifSearch", { // UPDATE -export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage> +export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, HazelApiClient> const fetch = (model: Model, query: string, page: number, append: boolean): Return => { const requestId = model.requestId + 1 diff --git a/apps/web-foldkit/src/notification-sound.test.ts b/apps/web-foldkit/src/notification-sound.test.ts index 2251aacf0..d9eaa7eef 100644 --- a/apps/web-foldkit/src/notification-sound.test.ts +++ b/apps/web-foldkit/src/notification-sound.test.ts @@ -4,7 +4,13 @@ import { Effect, Fiber, Schema, Stream } from "effect" import { beforeEach, describe, expect, test, vi } from "vitest" import { notificationSoundManager } from "~/lib/notification-sound-manager" import { wireNotificationSinks } from "./app/notification-sinks" -import { DEFAULT_SOUND_SETTINGS, loadSoundSettings, saveSoundSettings } from "./notification-sound" +import { + DEFAULT_SOUND_SETTINGS, + isInQuietHours, + isSoundMuted, + loadSoundSettings, + saveSoundSettings, +} from "./notification-sound" /** The root's notification sound wiring (legacy `NotificationSoundProvider`) and its stored settings. */ @@ -22,6 +28,39 @@ describe("sound settings", () => { }) }) +describe("quiet hours", () => { + const at = (time: string) => new Date(`2025-01-01T${time}:00`).getTime() + + test("off when not configured", () => { + expect(isInQuietHours(null, "08:00", at("23:00"))).toBe(false) + expect(isInQuietHours("22:00", null, at("23:00"))).toBe(false) + }) + + test("minute precision within a day", () => { + expect(isInQuietHours("10:15", "10:45", at("10:30"))).toBe(true) + expect(isInQuietHours("10:31", "10:45", at("10:30"))).toBe(false) + }) + + test("a window spanning midnight", () => { + expect(isInQuietHours("22:15", "06:45", at("23:30"))).toBe(true) + expect(isInQuietHours("22:15", "06:45", at("12:00"))).toBe(false) + }) + + test("sounds mute when disabled, in do not disturb, or in quiet hours", () => { + const inputs = { + settings: DEFAULT_SOUND_SETTINGS, + doNotDisturb: false, + quietHoursStart: "22:00", + quietHoursEnd: "08:00", + nowMs: at("12:00"), + } + expect(isSoundMuted(inputs)).toBe(false) + expect(isSoundMuted({ ...inputs, settings: { ...DEFAULT_SOUND_SETTINGS, enabled: false } })).toBe(true) + expect(isSoundMuted({ ...inputs, doNotDisturb: true })).toBe(true) + expect(isSoundMuted({ ...inputs, nowMs: at("23:00") })).toBe(true) + }) +}) + describe("notification sinks", () => { test("Test sound plays with the wired settings, and is not ready before", async () => { const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined) diff --git a/apps/web-foldkit/src/notification-sound.ts b/apps/web-foldkit/src/notification-sound.ts index 91c499ebf..881f9c279 100644 --- a/apps/web-foldkit/src/notification-sound.ts +++ b/apps/web-foldkit/src/notification-sound.ts @@ -3,7 +3,6 @@ import { readStored, writeStored } from "./data/storage" /** The notification sound settings the root owns (`Shared.soundSettings`); the sinks are in `app/`. */ -/** `NotificationSoundSettings` (`atoms/notification-sound-atoms.ts`). */ export const SoundFile = Schema.Literals(["notification01", "notification03"]) export type SoundFile = typeof SoundFile.Type @@ -22,7 +21,7 @@ export const DEFAULT_SOUND_SETTINGS: SoundSettings = { cooldownMs: 1000, } -/** Same key and codec as `notificationSoundSettingsAtom`. */ +/** The legacy app's key and codec, so saved settings carry over. */ const SOUND_SETTINGS_KEY = "notification-sound-settings" const SoundSettingsCodec = Schema.toCodecIso(Schema.NullOr(SoundSettings)) @@ -33,3 +32,31 @@ export const loadSoundSettings: Effect.Effect<SoundSettings> = readStored( export const saveSoundSettings = (settings: SoundSettings) => writeStored(SOUND_SETTINGS_KEY, SoundSettingsCodec, settings) + +const minutesOfDay = (time: string): number => { + const [hours, minutes] = time.split(":").map(Number) + return (hours ?? 0) * 60 + (minutes ?? 0) +} + +/** Whether `nowMs` (local time) falls in the user's quiet hours; a window may span midnight. */ +export const isInQuietHours = (quietHoursStart: string | null, quietHoursEnd: string | null, nowMs: number): boolean => { + if (!quietHoursStart || !quietHoursEnd) return false + const now = new Date(nowMs) + const current = now.getHours() * 60 + now.getMinutes() + const start = minutesOfDay(quietHoursStart) + const end = minutesOfDay(quietHoursEnd) + if (start === end) return false + return start < end ? current >= start && current < end : current >= start || current < end +} + +export interface SoundMuteInputs { + readonly settings: SoundSettings | null + readonly doNotDisturb: boolean + readonly quietHoursStart: string | null + readonly quietHoursEnd: string | null + readonly nowMs: number +} + +/** Sounds are muted when disabled, in do not disturb, or during quiet hours. */ +export const isSoundMuted = ({ settings, doNotDisturb, quietHoursStart, quietHoursEnd, nowMs }: SoundMuteInputs) => + !settings?.enabled || doNotDisturb || isInQuietHours(quietHoursStart, quietHoursEnd, nowMs) diff --git a/apps/web-foldkit/src/overlay/command-palette/commands.ts b/apps/web-foldkit/src/overlay/command-palette/commands.ts index 9e94a8169..5bccecbff 100644 --- a/apps/web-foldkit/src/overlay/command-palette/commands.ts +++ b/apps/web-foldkit/src/overlay/command-palette/commands.ts @@ -7,7 +7,7 @@ import { PresenceStatus } from "../../platform/presence/model" import { HazelRpc } from "../../rpc" import { Message } from "./message" import { ChannelType } from "./model" -import { failureToast, runAtomFn } from "../../data/actions" +import { failureToast } from "../../data/actions" /** Side effects of the palette pages, each the call legacy makes from the same handler. */ @@ -16,7 +16,7 @@ export const CreateChannel = Command.define("CreateChannel", { args: { name: Schema.String, type: ChannelType, organizationId: OrganizationId, currentUserId: UserId }, messages: [Message.SucceededCreateChannel, Message.FailedCreateChannel], execute: ({ name, type, organizationId, currentUserId }) => - runAtomFn(createChannelAction, { + createChannelAction({ name, icon: null, type, @@ -34,7 +34,7 @@ export const JoinChannel = Command.define("JoinChannel", { args: { channelId: ChannelId, userId: UserId }, messages: [Message.SucceededJoinChannel, Message.FailedJoinChannel], execute: ({ channelId, userId }) => - runAtomFn(joinChannelAction, { channelId, userId }).pipe( + joinChannelAction({ channelId, userId }).pipe( Effect.as(Message.SucceededJoinChannel()), Effect.catchCause((cause) => Effect.succeed( diff --git a/apps/web-foldkit/src/overlay/modal/create-section.ts b/apps/web-foldkit/src/overlay/modal/create-section.ts index 1b669671c..7ef58aeac 100644 --- a/apps/web-foldkit/src/overlay/modal/create-section.ts +++ b/apps/web-foldkit/src/overlay/modal/create-section.ts @@ -13,7 +13,7 @@ import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" -import { failureToast, runAtomFn, successToast } from "../../data/actions" +import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-section-modal.tsx` (legacy `useModal("create-section")`). */ @@ -40,7 +40,7 @@ export const CreateSection = Command.define("CreateSection", { args: { name: Schema.String, organizationId: OrganizationId }, messages: [Message.SucceededCreateSection, Message.FailedCreateSection], execute: (args) => - runAtomFn(createChannelSectionAction, args).pipe( + createChannelSectionAction(args).pipe( Effect.as(Message.SucceededCreateSection()), Effect.catchCause((cause) => Effect.succeed(Message.FailedCreateSection({ toast: failureToast(cause, "friendly") }))), ), diff --git a/apps/web-foldkit/src/overlay/modal/delete-channel.ts b/apps/web-foldkit/src/overlay/modal/delete-channel.ts index 9628493ac..6a9139db0 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-channel.ts @@ -13,7 +13,7 @@ import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" -import { failureToast, runAtomFn, successToast } from "../../data/actions" +import { failureToast, successToast } from "../../data/actions" /** `components/modals/delete-channel-modal.tsx`; confirming runs `deleteChannelAction`. */ @@ -37,7 +37,7 @@ const DeleteChannel = Command.define("DeleteChannel", { args: { channelId: ChannelId, orgSlug: Schema.NullOr(Schema.String) }, messages: [Message.SucceededDeleteChannel, Message.FailedDeleteChannel], execute: ({ channelId, orgSlug }) => - runAtomFn(deleteChannelAction, { channelId }).pipe( + deleteChannelAction({ channelId }).pipe( Effect.andThen(() => Effect.sync(() => { const pathname = window.location.pathname diff --git a/apps/web-foldkit/src/overlay/modal/delete-message.ts b/apps/web-foldkit/src/overlay/modal/delete-message.ts index e8a6a87ae..6ee62ba29 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-message.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-message.ts @@ -10,7 +10,6 @@ import { closed } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" -import { runAtomFn } from "../../data/actions" /** `components/chat/delete-message-modal.tsx`; confirming runs the toolbar's `deleteMessageAction`. */ @@ -30,7 +29,7 @@ const DeleteMessage = Command.define("DeleteMessage", { messages: [Message.CompletedDeleteMessage], // Legacy `handleDelete` fires the action without a toast either way. execute: ({ messageId }) => - runAtomFn(deleteMessageAction, { messageId }).pipe( + deleteMessageAction({ messageId }).pipe( Effect.ignoreCause, Effect.as(Message.CompletedDeleteMessage()), ), diff --git a/apps/web-foldkit/src/overlay/modal/join-channel.ts b/apps/web-foldkit/src/overlay/modal/join-channel.ts index 875742d78..15ea936e3 100644 --- a/apps/web-foldkit/src/overlay/modal/join-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/join-channel.ts @@ -18,7 +18,7 @@ import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" -import { failureToast, runAtomFn, successToast } from "../../data/actions" +import { failureToast, successToast } from "../../data/actions" /** `components/modals/join-channel-modal.tsx` (legacy `useModal("join-channel")`). */ @@ -59,7 +59,7 @@ const JoinChannel = Command.define("JoinChannel", { args: { channelId: ChannelId, userId: UserId }, messages: [Message.SucceededJoinChannel, Message.FailedJoinChannel], execute: ({ channelId, userId }) => - runAtomFn(joinChannelAction, { channelId, userId }).pipe( + joinChannelAction({ channelId, userId }).pipe( Effect.as(Message.SucceededJoinChannel()), Effect.catchCause((cause) => Effect.succeed( diff --git a/apps/web-foldkit/src/overlay/modal/new-channel.ts b/apps/web-foldkit/src/overlay/modal/new-channel.ts index bd4ca1c02..1c821a031 100644 --- a/apps/web-foldkit/src/overlay/modal/new-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/new-channel.ts @@ -20,7 +20,7 @@ import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" -import { failureToast, runAtomFn, successToast } from "../../data/actions" +import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-channel-modal.tsx` (legacy `useModal("new-channel")`). */ @@ -59,7 +59,7 @@ const CreateChannel = Command.define("CreateChannel", { }, messages: [Message.SucceededCreateChannel, Message.FailedCreateChannel], execute: (args) => - runAtomFn(createChannelAction, { ...args, icon: null, parentChannelId: null }).pipe( + createChannelAction({ ...args, icon: null, parentChannelId: null }).pipe( Effect.map((result) => Message.SucceededCreateChannel({ channelId: result.data.channelId })), Effect.catchCause((cause) => Effect.succeed(Message.FailedCreateChannel({ toast: failureToast(cause, "friendly") }))), ), diff --git a/apps/web-foldkit/src/overlay/modal/rename-channel.ts b/apps/web-foldkit/src/overlay/modal/rename-channel.ts index e229016be..c058fc201 100644 --- a/apps/web-foldkit/src/overlay/modal/rename-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/rename-channel.ts @@ -16,7 +16,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput, type ModalViewInputs } from "./contract" -import { failureToast, runAtomFn, successToast } from "../../data/actions" +import { failureToast, successToast } from "../../data/actions" import { Frame, Message as FrameMessage, @@ -77,7 +77,7 @@ const RenameChannel = Command.define("RenameChannel", { }, messages: [Message.SucceededRenameChannel, Message.FailedRenameChannel], execute: ({ channelId, name, notFoundTitle, notFoundDescription }) => - runAtomFn(updateChannelAction, { channelId, name }).pipe( + updateChannelAction({ channelId, name }).pipe( Effect.as(Message.SucceededRenameChannel()), Effect.catchCause((cause) => Effect.succeed( @@ -256,7 +256,6 @@ const spec = renameModalSpec({ notFound: { title: "Channel not found", description: "This channel may have been deleted.", - isRetryable: false, }, }) diff --git a/apps/web-foldkit/src/overlay/modal/rename-thread.ts b/apps/web-foldkit/src/overlay/modal/rename-thread.ts index b1e488954..6ed1bd03e 100644 --- a/apps/web-foldkit/src/overlay/modal/rename-thread.ts +++ b/apps/web-foldkit/src/overlay/modal/rename-thread.ts @@ -15,7 +15,6 @@ const spec = renameModalSpec({ notFound: { title: "Thread not found", description: "This thread may have been deleted.", - isRetryable: false, }, }) diff --git a/apps/web-foldkit/src/page/README.md b/apps/web-foldkit/src/page/README.md index 521810441..2646abbe3 100644 --- a/apps/web-foldkit/src/page/README.md +++ b/apps/web-foldkit/src/page/README.md @@ -40,17 +40,16 @@ memoized views and a `sharedChanged` hook. `page/root/` shows a page that only r Tests spread `sharedDefaults` (`page/test-shared.ts`) into their `Shared` literal. - **RPC and HTTP API.** Commands `yield* HazelRpc`, or `yield* HazelApiClient` for the HTTP API (legacy `HazelApiClient`: base URL and authenticated fetch). Both are the app's `resources` (`src/rpc.ts`). - Map every `Exit` to a Message (`SucceededX` / `FailedX`). -- **Optimistic mutations.** Run the legacy `db/actions` atoms with `runAtomFn` (`src/data/actions.ts`, one - registry for the app) and map the result with `settle`, `successToast` and `failureToast`. + Settle every Command into `SucceededX` / `FailedX({ reason })` following the error convention + (`docs/foldkit-decisions/s6-errors.md`): `Effect.catchTags` for the feature's errors, then `catchCommon`. +- **Optimistic mutations.** The `db/actions` actions are functions returning Effects + (`updateChannelAction({ channelId, name })`); they need `HazelRpc`. - **Toast helpers.** `src/data/actions.ts` is the only toast module (pages, modals, palette, shell): - `successToast` / `errorToast` / `warningToast` / `infoToast` / `loadingToast` build a `ToastRequest`; - `failureToast(cause, fallback, handlers?)` is legacy `exitToast`'s error branch and `toastOfExit` its - whole `run()`. Handlers are `onErrorTag` messages by tag (a fixed message or a function of the error). - `fallback` keeps legacy's wording for unhandled errors: `"exitToast"` (common-error map, else "An error - occurred") or `"friendly"` (`getUserFriendlyError`: network, timeout, the error's `message`). Pick the - one the legacy call site uses; where legacy shows a fixed string, use `errorToast` and drop the cause. - A failure Message carries `{ toast: ToastRequest }`, built in the Command, never `{ title, description }`. + `successToast` / `errorToast` / `warningToast` / `infoToast` / `loadingToast` build a `ToastRequest`, and + `errorToastOf(failureMessage(reason, {...}))` turns a failure reason into one in `update`. + Legacy, being migrated: `failureToast(cause, fallback, handlers?)` / `toastOfExit` and `Failed*{ toast }` + Messages. Handlers are keyed by the failure's own tags; unhandled errors get the shared copy and never + show error text. `fallback` is ignored. - **Live queries.** Use `liveQueryStream` (`src/data/live-query.ts`) with the legacy `useLiveQuery` builder, inside an `entry` whose dependencies come from `input.model` and `input.shared`. Return `Stream.empty` until the dependencies exist. Keys are prefixed with the page id automatically. diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/command.ts b/apps/web-foldkit/src/page/channel-settings/integrations/command.ts index 4a8d6914f..438ac3da2 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/command.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/command.ts @@ -1,13 +1,14 @@ +import type { ChannelNotFoundError } from "@hazel/domain/rpc" import { ChannelId, ChannelWebhookId, GitHubSubscriptionId, RssSubscriptionId } from "@hazel/schema" import { Duration, Effect, Exit, Schema } from "effect" import { Command } from "foldkit" import { toDate } from "~/lib/utils" import { HazelRpc } from "../../../rpc" -import { type ErrorHandlers, failureToast } from "../../../data/actions" +import { type ErrorHandlers, type ErrorMessage, failureToast } from "../../../data/actions" import { Message } from "./message" import { INTEGRATION_CONFIG, Provider, RowKind } from "./model" -const channelNotFound: ErrorHandlers = { +const channelNotFound: ErrorHandlers<ChannelNotFoundError> = { ChannelNotFoundError: { title: "Channel not found", description: "This channel may have been deleted.", @@ -111,7 +112,7 @@ export const ListGitHub = Command.define("ListGitHub", { // ROW ACTIONS (each row's toggle and delete, with its own messages) -const notFound = (title: string, description: string): ErrorHandlers[string] => ({ +const notFound = (title: string, description: string): ErrorMessage => ({ title, description, isRetryable: false, diff --git a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts index f144dae3d..c1d29631c 100644 --- a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts @@ -14,7 +14,6 @@ import { GenerateThreadName, PinMessage, ToggleReaction, - TrackEmojiUsage, UnpinMessage, } from "../message-actions" import * as Overlays from "../overlays" @@ -42,16 +41,12 @@ const out = (action: Overlays.MessageAction, messageId = graceMessageId) => Overlays.OutMessage.RequestedMessageAction({ messageId, action }) describe("reactions", () => { - test("a toolbar quick reaction counts the emoji and toggles through `messageReaction.toggle`", () => { + test("a toolbar quick reaction toggles through `messageReaction.toggle`", () => { story( updateWithShared, given(loadedModel()), message(overlays(Overlays.Message.ClickedReaction({ messageId: graceMessageId, emoji: "👍" }))), - Command.expectExact( - TrackEmojiUsage({ emoji: "👍" }), - ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), - ), - Command.resolve(TrackEmojiUsage, ActionMessage.CompletedTrackEmojiUsage()), + Command.expectExact(ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada })), Command.resolve(ToggleReaction, ActionMessage.CompletedToggleReaction({ toast: null })), ) }) diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 2e1f750f3..753617b72 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -18,7 +18,7 @@ import type { EmojiData } from "../../../emoji-picker/data" import * as Overlays from "../overlays" import { imageAttachmentOf } from "../../../test/chat-messages" import { PageOutMessage } from "../../out-message" -import { ActionMessage, CreateThread, DownloadImage, GenerateThreadChannelId, ToggleReaction, TrackEmojiUsage } from "../message-actions" +import { ActionMessage, CreateThread, DownloadImage, GenerateThreadChannelId, ToggleReaction } from "../message-actions" import { ada, adaMessageId, channelId, graceMessageId, updateWithShared } from "./fixtures.test-support" /** The channel page through its view: reading, the hover toolbar's actions and the composer indicators. */ @@ -39,7 +39,7 @@ describe("reading", () => { }) describe("hover toolbar", () => { - test("react: the quick reaction counts the emoji and toggles it", () => { + test("react: the quick reaction toggles it", () => { Scene.scene( channelScene, Scene.given(derivedModel()), @@ -47,10 +47,8 @@ describe("hover toolbar", () => { mountedToolbar(false), Scene.click(Scene.role("button", { name: "React with 👍" })), Scene.Command.expectExact( - TrackEmojiUsage({ emoji: "👍" }), ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), ), - Scene.Command.resolve(TrackEmojiUsage, ActionMessage.CompletedTrackEmojiUsage()), Scene.Command.resolve(ToggleReaction, ActionMessage.CompletedToggleReaction({ toast: null })), ) }) diff --git a/apps/web-foldkit/src/page/chat/channel/model.ts b/apps/web-foldkit/src/page/chat/channel/model.ts index b8cea7081..94d31e0b0 100644 --- a/apps/web-foldkit/src/page/chat/channel/model.ts +++ b/apps/web-foldkit/src/page/chat/channel/model.ts @@ -6,7 +6,7 @@ import * as Live from "../../../chat/live-state" import * as Unfurl from "../../../chat/unfurl" import * as Draft from "../../../composer/draft" import * as MessageList from "../../../mount/message-list" -import type { HazelRpc } from "../../../rpc" +import type { HazelApiClient, HazelRpc } from "../../../rpc" import type { PageOutMessage } from "../../out-message" import { AttachmentInfo, @@ -137,5 +137,5 @@ export const Message = defineMessageUnion({ }) export type Message = typeof Message.Type -export type PageReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc> +export type PageReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc | HazelApiClient> diff --git a/apps/web-foldkit/src/page/chat/channel/subscription.ts b/apps/web-foldkit/src/page/chat/channel/subscription.ts index 64ba21564..5ffcc4b2d 100644 --- a/apps/web-foldkit/src/page/chat/channel/subscription.ts +++ b/apps/web-foldkit/src/page/chat/channel/subscription.ts @@ -36,7 +36,7 @@ import * as Draft from "../../../composer/draft" import * as Typing from "../../../composer/typing" import { uploadStream } from "../../../composer/upload" import { MessageWindows } from "../../../data/message-windows" -import type { HazelRpc } from "../../../rpc" +import type { HazelApiClient, HazelRpc } from "../../../rpc" import { globalTypingStream, leftWindowStream } from "../../../composer/window-events" import { isMemberOf, Message, type Model } from "./page" @@ -293,7 +293,7 @@ export const typingCleanupStream = <R>( ) /** The write path's streams: composer data, uploads, global typing and window blur. */ -const write = Subscription.make<Input, Message, HazelRpc>()((entry) => ({ +const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((entry) => ({ composerMembers: entry(byChannel, { modelToDependencies: channelOf, dependenciesToStream: ({ channelId }) => diff --git a/apps/web-foldkit/src/page/chat/channel/write.ts b/apps/web-foldkit/src/page/chat/channel/write.ts index 2c5168c6e..27d52a3cf 100644 --- a/apps/web-foldkit/src/page/chat/channel/write.ts +++ b/apps/web-foldkit/src/page/chat/channel/write.ts @@ -22,7 +22,6 @@ import { OpenUrl, PinMessage, ToggleReaction, - TrackEmojiUsage, UnpinMessage, } from "../message-actions" import * as Overlays from "../overlays" @@ -132,16 +131,13 @@ const findMessage = (model: Model, messageId: MessageId) => model.messages.find((message) => message.id === messageId) ?? model.threadMessages.find((message) => message.id === messageId) -/** `handleReaction`: count the emoji, then toggle (`toggleReactionAction`). */ +/** `handleReaction`: toggle the reaction (`toggleReactionAction`). */ export const react = (model: Model, messageId: MessageId, emoji: string): PageReturn => { if (model.currentUserId === null) return { model } const channelId = findMessage(model, messageId)?.channelId ?? model.channelId return { model, - commands: actionCommands([ - TrackEmojiUsage({ emoji }), - ToggleReaction({ messageId, channelId, emoji, userId: model.currentUserId }), - ]), + commands: actionCommands([ToggleReaction({ messageId, channelId, emoji, userId: model.currentUserId })]), } } @@ -275,7 +271,6 @@ export const handleActionMessage = (model: Model, message: ActionMessage): PageR CompletedCopyText: () => ({ model }), CompletedDownloadImage: () => ({ model }), CompletedOpenUrl: () => ({ model }), - CompletedTrackEmojiUsage: () => ({ model }), CompletedGenerateThreadName: ({ toast: request }) => withToast({ ...model, isGeneratingThreadName: false }, request), }) diff --git a/apps/web-foldkit/src/page/chat/message-actions.ts b/apps/web-foldkit/src/page/chat/message-actions.ts index 9fd402557..c72c23a6a 100644 --- a/apps/web-foldkit/src/page/chat/message-actions.ts +++ b/apps/web-foldkit/src/page/chat/message-actions.ts @@ -2,7 +2,6 @@ import { ChannelId, MessageId, OrganizationId, PinnedMessageId, UserId } from "@ import { Effect, Schema } from "effect" import { Command } from "foldkit" import { defineMessageUnion } from "foldkit/message" -import { emojiUsageAtom } from "~/atoms/emoji-atoms" import { createThreadAction, deleteMessageAction, @@ -12,9 +11,8 @@ import { } from "~/db/actions" import { ToastRequest } from "../../overlay/toasts" import { HazelRpc } from "../../rpc" -import { runAtomFn, toastOfExit } from "../../data/actions" +import { toastOfExit } from "../../data/actions" import { notRetryable, rateLimited } from "./action-effects" -import { trackEmojiUsage } from "../../emoji-picker/usage" /** * The message actions of `ChatProvider` and `useMessageActions`: each Command runs the same @@ -34,7 +32,6 @@ export const ActionMessage = defineMessageUnion({ CompletedCopyText: {}, CompletedDownloadImage: {}, CompletedOpenUrl: {}, - CompletedTrackEmojiUsage: {}, CompletedGenerateThreadName: { threadChannelId: ChannelId, toast: Schema.NullOr(ToastRequest) }, }) export type ActionMessage = typeof ActionMessage.Type @@ -51,7 +48,7 @@ export const ToggleReaction = Command.define("ToggleReaction", { args: { messageId: MessageId, channelId: ChannelId, emoji: Schema.String, userId: UserId }, messages: [ActionMessage.CompletedToggleReaction], execute: (args) => - toastOfExit(runAtomFn(toggleReactionAction, args), "exitToast", { + toastOfExit(toggleReactionAction(args), "exitToast", { handlers: { MessageNotFoundError: messageNotFound }, }).pipe(Effect.map((toast) => ActionMessage.CompletedToggleReaction({ toast }))), }) @@ -60,7 +57,7 @@ export const PinMessage = Command.define("PinMessage", { args: { messageId: MessageId, channelId: ChannelId, userId: UserId }, messages: [ActionMessage.CompletedPinMessage], execute: (args) => - toastOfExit(runAtomFn(pinMessageAction, args), "exitToast", { + toastOfExit(pinMessageAction(args), "exitToast", { success: "Message pinned", handlers: { MessageNotFoundError: messageNotFound }, }).pipe(Effect.map((toast) => ActionMessage.CompletedPinMessage({ toast }))), @@ -70,7 +67,7 @@ export const UnpinMessage = Command.define("UnpinMessage", { args: { pinnedMessageId: PinnedMessageId }, messages: [ActionMessage.CompletedUnpinMessage], execute: (args) => - toastOfExit(runAtomFn(unpinMessageAction, args), "exitToast", { + toastOfExit(unpinMessageAction(args), "exitToast", { success: "Message unpinned", handlers: { PinnedMessageNotFoundError: notRetryable( @@ -85,7 +82,7 @@ export const DeleteMessage = Command.define("DeleteMessage", { args: { messageId: MessageId }, messages: [ActionMessage.CompletedDeleteMessage], execute: (args) => - toastOfExit(runAtomFn(deleteMessageAction, args), "exitToast", { + toastOfExit(deleteMessageAction(args), "exitToast", { handlers: { RateLimitExceededError: rateLimited("trying again"), MessageNotFoundError: notRetryable( @@ -119,7 +116,7 @@ export const CreateThread = Command.define("CreateThread", { }, messages: [ActionMessage.SucceededCreateThread, ActionMessage.FailedCreateThread], execute: (args) => - toastOfExit(runAtomFn(createThreadAction, args), "exitToast", { + toastOfExit(createThreadAction(args), "exitToast", { handlers: { MessageNotFoundError: notRetryable("Message not found", "The message no longer exists"), NestedThreadError: notRetryable( @@ -175,16 +172,6 @@ export const OpenUrl = Command.define("OpenUrl", { }), }) -/** `useEmojiStats().trackEmojiUsage`: counts the emoji in the persisted usage atom. */ -export const TrackEmojiUsage = Command.define("TrackEmojiUsage", { - args: { emoji: Schema.String }, - messages: [ActionMessage.CompletedTrackEmojiUsage], - execute: ({ emoji }) => - Effect.sync(() => trackEmojiUsage(emojiUsageAtom, emoji)).pipe( - Effect.as(ActionMessage.CompletedTrackEmojiUsage()), - ), -}) - /** The thread panel's "Generate thread name" (`channel.generateName`). */ export const GenerateThreadName = Command.define("GenerateThreadName", { args: { channelId: ChannelId }, diff --git a/apps/web-foldkit/src/page/join/command.ts b/apps/web-foldkit/src/page/join/command.ts index 39dbb1836..da78f273e 100644 --- a/apps/web-foldkit/src/page/join/command.ts +++ b/apps/web-foldkit/src/page/join/command.ts @@ -1,3 +1,4 @@ +import type { AlreadyMemberError, OrganizationNotFoundError, PublicInviteDisabledError } from "@hazel/domain/rpc" import { Effect, Exit, Schema } from "effect" import { Command } from "foldkit" import { load } from "foldkit/navigation" @@ -26,7 +27,7 @@ export const FetchPublicOrganization = Command.define("FetchPublicOrganization", }) /** The toasts legacy's `exitToastAsync(...).onErrorTag(...)` chain shows. */ -const knownFailures: ErrorHandlers = { +const knownFailures: ErrorHandlers<OrganizationNotFoundError | PublicInviteDisabledError | AlreadyMemberError> = { OrganizationNotFoundError: { title: "Organization not found", description: "This organization may have been deleted.", diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts index 85812e8a7..73f61dbf5 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts @@ -10,7 +10,7 @@ import type { ToastRequest } from "../../../overlay/toasts" import * as Modal from "../../../ui/modal" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, runAtomFn, successToast } from "../../../data/actions" +import { errorToast, successToast } from "../../../data/actions" import { ALLOWED_EMOJI_TYPES, MAX_EMOJI_SIZE, publicUrlOf, uploadFile } from "../upload" import { Message } from "./message" import type { Model } from "./model" @@ -100,7 +100,7 @@ export const CreateCustomEmoji = Command.define("CreateCustomEmoji", { }) } const exit = yield* Effect.exit( - runAtomFn(createCustomEmojiAction, { organizationId, name, imageUrl, createdBy }), + createCustomEmojiAction({ organizationId, name, imageUrl, createdBy }), ) if (exit._tag === "Success") return Message.SucceededCreateEmoji({ name, previewUrl }) return Option.match(deletedExistsErrorOf(exit.cause), { @@ -142,7 +142,7 @@ export const RestoreCustomEmoji = Command.define("RestoreCustomEmoji", { }, messages: [Message.SucceededRestoreEmoji, Message.FailedRestoreEmoji], execute: ({ previewUrl, ...args }) => - runAtomFn(restoreCustomEmojiAction, args).pipe( + restoreCustomEmojiAction(args).pipe( Effect.match({ onSuccess: () => Message.SucceededRestoreEmoji({ name: args.name, previewUrl }), onFailure: () => Message.FailedRestoreEmoji(), @@ -154,7 +154,7 @@ export const DeleteCustomEmoji = Command.define("DeleteCustomEmoji", { args: { emojiId: CustomEmojiId, name: Schema.String }, messages: [Message.SucceededDeleteEmoji, Message.FailedDeleteEmoji], execute: ({ emojiId, name }) => - runAtomFn(deleteCustomEmojiAction, { emojiId }).pipe( + deleteCustomEmojiAction({ emojiId }).pipe( Effect.match({ onSuccess: () => Message.SucceededDeleteEmoji({ name }), onFailure: () => Message.FailedDeleteEmoji(), diff --git a/apps/web-foldkit/src/page/settings/general/update.ts b/apps/web-foldkit/src/page/settings/general/update.ts index e99f7a0ba..a238f5dce 100644 --- a/apps/web-foldkit/src/page/settings/general/update.ts +++ b/apps/web-foldkit/src/page/settings/general/update.ts @@ -10,7 +10,7 @@ import { HazelRpc } from "../../../rpc" import type { RouteOf } from "../../../route" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, failureToast, runAtomFn, settle, successToast } from "../../../data/actions" +import { errorToast, failureToast, settle, successToast } from "../../../data/actions" import { publicUrlOf, uploadFile } from "../upload" import { Message } from "./message" import type { Model } from "./model" @@ -33,7 +33,7 @@ export const UpdateOrganizationName = Command.define("UpdateOrganizationName", { messages: [Message.SucceededUpdateName, Message.FailedUpdateName], execute: ({ organizationId, name }) => settle( - runAtomFn(updateOrganizationAction, { organizationId, name }), + updateOrganizationAction({ organizationId, name }), () => Message.SucceededUpdateName(), (cause) => Message.FailedUpdateName({ @@ -53,7 +53,7 @@ export const SetPublicMode = Command.define("SetPublicMode", { messages: [Message.SucceededSetPublicMode, Message.FailedSetPublicMode], execute: ({ organizationId, isPublic }) => settle( - runAtomFn(setPublicModeAction, { organizationId, isPublic }), + setPublicModeAction({ organizationId, isPublic }), () => Message.SucceededSetPublicMode({ isPublic }), (cause) => Message.FailedSetPublicMode({ toast: failureToast(cause, "friendly", organizationNotFound) }), @@ -104,7 +104,7 @@ export const UploadLogo = Command.define("UploadLogo", { ), }) } - return yield* runAtomFn(updateOrganizationAction, { organizationId, logoUrl }).pipe( + return yield* updateOrganizationAction({ organizationId, logoUrl }).pipe( Effect.as(Message.SucceededUploadLogo()), Effect.catch(() => Effect.succeed( diff --git a/apps/web-foldkit/src/shell/channels-sidebar/actions.ts b/apps/web-foldkit/src/shell/channels-sidebar/actions.ts index e6afa6c5d..021ab693c 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/actions.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/actions.ts @@ -6,7 +6,7 @@ import { moveChannelToSectionAction, updateChannelMemberAction, } from "~/db/actions" -import { failureToast, runAtomFn, settle, successToast } from "../../data/actions" +import { failureToast, settle, successToast } from "../../data/actions" import { HazelRpc } from "../../rpc" import { Message } from "./model" @@ -32,7 +32,7 @@ export const UpdateChannelMember = Command.define("UpdateChannelMember", { messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ memberId, field, value, successTitle }) => settle( - runAtomFn(updateChannelMemberAction, { memberId, [field]: value }), + updateChannelMemberAction({ memberId, [field]: value }), succeeded(successTitle), (cause) => Message.FailedSidebarAction({ toast: failureToast(cause, "friendly", memberNotFound) }), @@ -60,7 +60,7 @@ export const MoveChannelToSection = Command.define("MoveChannelToSection", { messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ channelId, sectionId }) => settle( - runAtomFn(moveChannelToSectionAction, { channelId, sectionId }), + moveChannelToSectionAction({ channelId, sectionId }), succeeded(sectionId ? "Channel moved to section" : "Channel moved to default"), (cause) => Message.FailedSidebarAction({ @@ -82,7 +82,7 @@ export const DeleteChannelSection = Command.define("DeleteChannelSection", { args: { sectionId: ChannelSectionId }, messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ sectionId }) => - settle(runAtomFn(deleteChannelSectionAction, { sectionId }), succeeded("Section deleted"), (cause) => + settle(deleteChannelSectionAction({ sectionId }), succeeded("Section deleted"), (cause) => Message.FailedSidebarAction({ toast: failureToast(cause, "friendly", { ChannelSectionNotFoundError: { diff --git a/docs/foldkit-decisions/s6-errors.md b/docs/foldkit-decisions/s6-errors.md new file mode 100644 index 000000000..9b7fb9915 --- /dev/null +++ b/docs/foldkit-decisions/s6-errors.md @@ -0,0 +1,105 @@ +# S6: Frontend error convention (apps/web-foldkit) + +Status: **adopted** · 2026-10-15 · foundation stream + +Every Command that can fail settles into exactly one Message. Failures are **typed reasons**, never strings, toasts +or raw `error.message`. `update` turns the reason into copy. Defects are caught in one place. + +## 1. The Failure Message + +```ts +// <feature>/message.ts (or next to the Command) +import { CommonReason } from "~/lib/error-messages" + +/** What can go wrong when renaming, beyond the shared reasons. */ +export const RenameFailure = Schema.Union([Schema.Literals(["NotFound", "NameTaken"]), CommonReason]) +export type RenameFailure = typeof RenameFailure.Type + +FailedRenameChannel: { reason: RenameFailure }, +``` + +- Name: `Failed<CommandName>`. Field: `reason`. Nothing else unless update needs it (e.g. the id the result is for). +- Reason = feature literals (or a tagged union when a case carries data) **unioned with `CommonReason`**. +- No `toast: ToastRequest`, no `message: string`, no `reason: String(error)`, no `isUnauthorized: boolean` payloads. + If nothing reads the reason, still carry it (it's cheap and makes the Message honest) — or drop the Failed variant + only if the failure genuinely can't happen. + +`CommonReason` (from `~/lib/error-messages`) is the closed set every RPC / HttpApi / optimistic-action failure collapses to: + +| reason | from | +| --- | --- | +| `Offline` | `HttpClientError` with `reason._tag === "TransportError"`, `RpcClientError` whose HTTP `kind` is `TransportError`, socket errors | +| `Timeout` | `TimeoutError`, `TxIdTimeoutError` | +| `SignedOut` | `UnauthorizedError`(401 middleware) / `Session*Error` / `InvalidJwtPayloadError` / `InvalidBearerTokenError` | +| `RateLimited` | `RateLimitExceededError` | +| `Unavailable` | `SessionLoadError`, `WorkflowServiceUnavailableError`, 5xx `StatusCodeError`, `CollectionSyncEffectError` | +| `Unexpected` | `InternalServerError`, `SchemaError`, decode/encode errors, `OptimisticActionError`, TanStack key errors, defects | + +## 2. Building the reason in the Command: `Effect.catchTags` + `catchCommon` + +```ts +import { catchCommon } from "../../data/actions" + +const RenameChannel = Command.define("RenameChannel", { + args: { channelId: ChannelId, name: Schema.String }, + messages: [Message.SucceededRenameChannel, Message.FailedRenameChannel], + execute: ({ channelId, name }) => + updateChannelAction({ channelId, name }).pipe( + Effect.as(Message.SucceededRenameChannel()), + // 1. the errors this feature has words for + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(Message.FailedRenameChannel({ reason: "NotFound" })), + ChannelNameTakenError: () => Effect.succeed(Message.FailedRenameChannel({ reason: "NameTaken" })), + }), + // 2. everything left must be a CommonError (compile error otherwise), plus defects + catchCommon((reason) => Message.FailedRenameChannel({ reason })), + ), +}) +``` + +- `catchCommon(onReason)` is **the only `catchCause`** in feature code. It requires the remaining `E` to extend + `CommonError`, so an un-handled feature error (e.g. `BotNotInstalledError`) is a type error, not a silent "Failed". + Defects are logged with `Effect.logError` and become `"Unexpected"`; interrupts stay interrupts. +- Do **not** use `Effect.catch(() => …)`, `Effect.catchCause(…)`, `Effect.ignore`, `orDie`, `_tag` string sniffing, + `String(error)` or `error.message` in Commands. Best-effort work (focus, telemetry, typing-indicator delete) may + `Effect.ignore` with a one-line comment saying why. +- Local fallible code: `Effect.tryPromise({ try, catch: (cause) => new XError({ cause }) })` with a + `Schema.TaggedError` (the repo's form: `class XError extends Schema.TaggedError<XError>()("XError", {...}) {}`) defined next to it, then `catchTags` it like above. Never `Effect.promise` + around something that can reject (a defect crashes the whole app). +- Optimistic actions (`~/db/actions`) are plain functions returning Effects now: `sendMessageAction(props)`, + `updateChannelAction(props)` … (`runAtomFn` is gone). They need `HazelRpc` (a Foldkit resource, already provided to Commands). Their error is the RPC's + declared errors `| SyncError | OptimisticActionError`, all handled by `catchTags` + `catchCommon`. +- RPC / HTTP API: `HazelRpc.use((rpc) => rpc("channel.update", payload))` and + `HazelApiClient.use((client) => client.group.endpoint(...))` from `src/rpc.ts`. No other client exists. + +## 3. Reason → copy, in `update` + +```ts +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf } from "../../data/actions" + +FailedRenameChannel: ({ reason }) => [ + evo(model, { submission: () => Submission.Failed({ reason }) }), + [ShowToast(errorToastOf(failureMessage(reason, { + NotFound: { title: "Channel not found", description: "It may have been deleted." }, + NameTaken: { title: "That name is taken" }, + })))], +], +``` + +- `failureMessage(reason, specific)`: `specific` must cover exactly the feature literals (`Exclude<R, CommonReason>`), + common reasons get the shared copy (`commonMessage(reason)`). Exhaustive by type. +- Views that render an inline error call the same `failureMessage` from the Model's stored reason. +- Copy never contains server text. `RateLimited` may use `retryAfterMs` only if the feature carries it in a tagged reason. + +## 4. Retry policy is separate from copy + +`isRetryableReason(reason)` (Offline, Timeout, Unavailable) and `isRetryableError(error: unknown)` (for +`Schedule.while`) live in `~/lib/error-messages`. Copy tables carry no `isRetryable` flag. The update decides whether to +offer a "Try again" button from `isRetryableReason`. + +## 5. Legacy, being migrated + +`failureToast(cause, fallback, handlers)` / `toastOfExit` / `settle` and `Failed*{ toast }` still exist so untouched +screens compile. They are now safe (typed `handlers` keyed by `E["_tag"]`, no raw text ever shown, defects logged), but +new or touched code uses sections 1–3. `fallback` is ignored and will be removed with the last caller. diff --git a/libs/effect-electric-db-collection/src/collection.ts b/libs/effect-electric-db-collection/src/collection.ts index 5bd4808fd..7e70f8203 100644 --- a/libs/effect-electric-db-collection/src/collection.ts +++ b/libs/effect-electric-db-collection/src/collection.ts @@ -411,7 +411,7 @@ export function createEffectCollection<A extends Row<unknown>, TRuntime>( "schema" > & { schema: Schema.Schema<A> - runtime: ManagedRuntime.ManagedRuntime<TRuntime, unknown> + runtime?: ManagedRuntime.ManagedRuntime<TRuntime, unknown> }, ): EffectCollection<A> { // Convert Effect Schema to StandardSchemaV1 internally. diff --git a/libs/effect-electric-db-collection/src/optimistic-action.ts b/libs/effect-electric-db-collection/src/optimistic-action.ts index 84d41b15f..847648764 100644 --- a/libs/effect-electric-db-collection/src/optimistic-action.ts +++ b/libs/effect-electric-db-collection/src/optimistic-action.ts @@ -1,15 +1,8 @@ -import { Atom, type AsyncResult } from "effect/reactivity" import type { Collection, Transaction } from "@tanstack/db" import { createTransaction } from "@tanstack/db" import type { Txid } from "@tanstack/electric-db-collection" -import { Cause, Effect, Exit, type ManagedRuntime } from "effect" -import { - type AwaitTxIdError, - type InvalidTxIdError, - OptimisticActionError, - SyncError, - type TxIdTimeoutError, -} from "./errors" +import { type Cause, Effect, Exit } from "effect" +import { type AwaitTxIdError, type InvalidTxIdError, OptimisticActionError, SyncError, type TxIdTimeoutError } from "./errors" /** * Collection with Effect-native utilities. @@ -79,11 +72,6 @@ export interface OptimisticActionConfig< */ collections: TCollections - /** - * ManagedRuntime for Effect execution - */ - runtime: ManagedRuntime.ManagedRuntime<TRequires, unknown> - /** * Optimistic mutation - synchronous, returns IDs/metadata */ @@ -126,17 +114,15 @@ function normalizeCollections(collections: CollectionInput): NormalizedCollectio } /** - * Sync all collections with transaction ID using pure Effect. - * Uses Effect.all for parallel execution with full Effect control. + * Waits until every collection has synced `txid`, in parallel. */ function syncAllCollections( collections: NormalizedCollection[], txid: Txid, timeout: number, -): Effect.Effect<void, SyncError | TxIdTimeoutError | InvalidTxIdError | AwaitTxIdError> { - return Effect.gen(function* () { - // Create sync effect for each collection - const syncEffects = collections.map(({ name, collection }) => +): Effect.Effect<void, SyncError> { + return Effect.all( + collections.map(({ name, collection }) => collection.utils.awaitTxIdEffect(txid, timeout).pipe( Effect.mapError( (error) => @@ -149,64 +135,37 @@ function syncAllCollections( }), ), ), - ) - - // Run all syncs in parallel using Effect.all - yield* Effect.all(syncEffects, { concurrency: "unbounded" }) - }) + ), + { concurrency: "unbounded", discard: true }, + ) } /** - * Creates an Effect-based optimistic action with automatic collection sync. + * Creates an optimistic action with automatic collection sync: a function from variables to an Effect that + * applies `onMutate` to the collections at once, runs `mutate` against the server, waits until every + * collection has synced the returned transaction id, and rolls the optimistic change back if any step fails. * - * Pure Effect implementation - no raw Promises for sync operations. - * Collections are declared upfront and sync happens automatically on all of them. + * The Effect requires what `mutate` requires (e.g. an RPC client), so the caller's runtime provides it. + * A typed failure of `mutate` or of the sync is kept as is; a defect stays a defect. * * @example * ```typescript - * // Single collection * const sendMessageAction = optimisticAction({ * collections: [messageCollection], - * runtime: runtime, * onMutate: (props) => { * const messageId = MessageId.make(crypto.randomUUID()) * messageCollection.insert({ id: messageId, ...props }) * return { messageId } * }, - * mutate: (props, ctx) => Effect.gen(function* () { - * const client = yield* RpcClient - * const result = yield* client.message.create(props) - * // No manual sync needed! Just return the transactionId + * mutate: (props) => Effect.gen(function* () { + * const rpc = yield* HazelRpc + * const result = yield* rpc("message.create", props) * return { data: result, transactionId: result.transactionId } * }), * }) * - * // Multiple collections - * const createDmChannel = optimisticAction({ - * collections: { - * channel: channelCollection, - * members: channelMemberCollection, - * }, - * runtime: runtime, - * onMutate: (props) => { - * channelCollection.insert({ ... }) - * channelMemberCollection.insert({ ... }) - * return { channelId } - * }, - * mutate: (props, ctx) => Effect.gen(function* () { - * const result = yield* client.channel.createDm(props) - * // Automatic sync on BOTH channel AND members collections! - * return { data: { channelId: result.data.id }, transactionId: result.transactionId } - * }), - * }) - * - * // In component: - * const sendMessage = useAtomSet(sendMessageAction, { mode: "promiseExit" }) - * const exit = await sendMessage({ content: "Hello" }) - * Exit.match(exit, { - * onSuccess: (result) => console.log("Sent:", result.data), - * onFailure: (cause) => console.error("Failed:", cause) - * }) + * // In a Command: + * sendMessageAction({ content: "Hello" }).pipe(Effect.as(SentMessage()), Effect.catchTags({ ... })) * ``` */ export function optimisticAction< @@ -218,113 +177,65 @@ export function optimisticAction< TMutateResult extends Record<string, unknown> = Record<string, unknown>, >( config: OptimisticActionConfig<TVariables, TSuccess, TError, TCollections, TRequires, TMutateResult>, -): Atom.Writable< - AsyncResult.AsyncResult< - OptimisticActionResult<TSuccess, TMutateResult>, - TError | SyncError | OptimisticActionError - >, - TVariables +): ( + variables: TVariables, +) => Effect.Effect< + OptimisticActionResult<TSuccess, TMutateResult>, + TError | SyncError | OptimisticActionError, + TRequires > { - const { collections, runtime, onMutate, mutate, syncTimeout = 30000 } = config + const { collections, onMutate, mutate, syncTimeout = 30000 } = config const normalizedCollections = normalizeCollections(collections) - return Atom.fn((variables: TVariables) => + return (variables) => Effect.gen(function* () { + const context = yield* Effect.context<TRequires>() let mutateResult!: TMutateResult let mutationResult!: MutationResultWithTxId<TSuccess> + /** Why `mutationFn` rejected, so the typed failure (or defect) survives TanStack's Promise. */ + let failure: Cause.Cause<TError | SyncError> | undefined - // Create transaction that wraps the Effect-based mutation const transaction = createTransaction({ autoCommit: true, mutationFn: async (params) => { - // Run the mutation Effect - const mutationEffect = mutate(variables, { - mutateResult, - transaction: params.transaction, - }) - - const exit = await runtime.runPromiseExit(mutationEffect) - - if (Exit.isFailure(exit)) { - const cause = exit.cause - const failReason = cause.reasons.find(Cause.isFailReason) - if (failReason) { - throw failReason.error // Typed TError - } - throw new OptimisticActionError({ - message: "Mutation failed unexpectedly", - cause: Cause.pretty(cause), - }) - } - - mutationResult = exit.value - - // Run automatic sync on ALL collections using Effect.all - const syncEffect = syncAllCollections( - normalizedCollections, - mutationResult.transactionId, - syncTimeout, + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + mutationResult = yield* mutate(variables, { mutateResult, transaction: params.transaction }) + yield* syncAllCollections(normalizedCollections, mutationResult.transactionId, syncTimeout) + return mutationResult.data + }).pipe(Effect.provideContext(context)), ) - - const syncExit = await runtime.runPromiseExit( - syncEffect as Effect.Effect< - void, - SyncError | TxIdTimeoutError | InvalidTxIdError | AwaitTxIdError, - never - >, - ) - - if (Exit.isFailure(syncExit)) { - const cause = syncExit.cause - const syncFailReason = cause.reasons.find(Cause.isFailReason) - if (syncFailReason) { - throw syncFailReason.error // SyncError - } - throw new SyncError({ - message: "Sync failed unexpectedly", - cause: Cause.pretty(cause), - }) - } - - return mutationResult.data + if (Exit.isSuccess(exit)) return exit.value + failure = exit.cause + throw new OptimisticActionError({ message: "Optimistic mutation failed" }) }, }) - // Execute optimistic mutation synchronously + // Apply the optimistic change synchronously; `autoCommit` then runs `mutationFn`. transaction.mutate(() => { mutateResult = onMutate(variables) }) - // Wait for transaction completion - const result = yield* Effect.tryPromise({ - try: () => transaction.isPersisted.promise, - catch: (error) => { - // Attempt rollback - if (transaction.state !== "completed" && transaction.state !== "failed") { - try { - transaction.rollback() - } catch { - // Ignore rollback errors - } - } - - // Preserve typed errors - if (error && typeof error === "object" && "_tag" in error) { - return error as TError | SyncError - } - - return new OptimisticActionError({ - message: error instanceof Error ? error.message : "Optimistic action failed", - cause: error, - }) - }, - }) + const persisted = yield* Effect.exit( + Effect.tryPromise({ + try: () => transaction.isPersisted.promise, + catch: (cause) => new OptimisticActionError({ message: "Optimistic action failed", cause }), + }), + ) + + if (Exit.isFailure(persisted)) { + // TanStack rolls back a transaction whose `mutationFn` rejected; this covers any other rejection. + if (transaction.state !== "completed" && transaction.state !== "failed") { + // Best effort: the failure below is what the caller needs to see. + yield* Effect.ignore(Effect.try(() => transaction.rollback())) + } + return yield* failure === undefined ? Effect.failCause(persisted.cause) : Effect.failCause(failure) + } return { data: mutationResult.data, mutateResult, transactionId: mutationResult.transactionId, } - }), - ) + }) } From 39c4698fc3ff2e1d450cd1de32875ef2da854095 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:53:32 +0200 Subject: [PATCH 05/52] refactor(auth): remove the Clerk sign-in path Hazel auth is the only sign-in now. Removed the Clerk JWT fallback in both auth middlewares, SessionManager and its per-request Clerk org sync, BackendAuth, ClerkClient/AuthConfig, the ClerkSync service and /webhooks/clerk route (whose failures returned 200 so Svix never redelivered), ClerkUserFetchError from the auth unions, the Clerk-only repo methods, the migrate-to-clerk scripts and the Clerk steps of the setup CLI. CLERK_SECRET_KEY is now optional and read only by OAuthBearerAuth (Maple's Clerk-issued OAuth tokens), marked as the last Clerk consumer. Database failures while resolving a session, a bot token or an OAuth user now fail with SessionLoadError (503) instead of a 401, so a DB blip no longer signs the user out; driver text is logged, not sent. GitHub webhook payloads are schema-decoded (InvalidGitHubWebhookPayload, 400) and a cluster outage answers 503 (WorkflowServiceUnavailableError) so GitHub can redeliver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../chat-sync/chat-sync-diagnostics.jsonl | 3 + apps/backend/.env.example | 9 +- apps/backend/README.md | 2 +- apps/backend/src/app.ts | 12 +- apps/backend/src/routes/webhooks.http.ts | 455 ++++++++---------- apps/backend/src/rpc/middleware/auth-class.ts | 50 -- apps/backend/src/rpc/middleware/auth.test.ts | 51 +- apps/backend/src/rpc/middleware/auth.ts | 54 +-- .../migrate-to-clerk/backfill-external-ids.ts | 140 ------ .../diagnose-org-duplicates.ts | 64 --- .../migrate-to-clerk/merge-org-duplicates.ts | 196 -------- apps/backend/src/services/auth.ts | 31 +- apps/backend/src/services/hazel-auth.test.ts | 10 - apps/backend/src/services/hazel-auth.ts | 6 +- apps/backend/src/services/hazel-session.ts | 17 +- .../backend/src/services/oauth-bearer-auth.ts | 69 ++- apps/backend/src/services/session-manager.ts | 120 ----- apps/backend/src/worker/env.ts | 9 +- apps/electric-proxy/resources.ts | 2 +- bun.lock | 3 - packages/auth/package.json | 2 - packages/auth/src/cache/index.ts | 2 - packages/auth/src/cache/user-lookup-cache.ts | 139 ------ .../auth/src/cache/user-lookup-request.ts | 28 -- packages/auth/src/config.ts | 30 -- packages/auth/src/consumers/backend-auth.ts | 248 ---------- packages/auth/src/errors.ts | 37 -- packages/auth/src/index.ts | 12 - packages/auth/src/metrics.ts | 25 - packages/auth/src/session/clerk-client.ts | 124 ----- packages/auth/src/session/index.ts | 2 - packages/auth/src/session/jwt-decoder.ts | 33 -- packages/auth/src/types.ts | 18 - packages/backend-core/package.json | 2 - packages/backend-core/src/index.ts | 2 - .../repositories/organization-member-repo.ts | 1 - .../src/repositories/organization-repo.ts | 77 +-- .../src/repositories/user-repo.ts | 23 +- .../backend-core/src/services/clerk-sync.ts | 299 ------------ packages/backend-core/src/services/index.ts | 1 - packages/domain/src/current-user.ts | 2 - .../domain/src/http/api-v1/integrations.ts | 9 +- packages/domain/src/http/webhooks.ts | 53 +- packages/domain/src/index.ts | 1 - packages/domain/src/rpc/middleware.ts | 6 +- packages/domain/src/session-errors.ts | 8 - packages/domain/src/session.ts | 1 - packages/schema/src/clerk.ts | 40 +- packages/setup/package.json | 1 - packages/setup/src/commands/env.ts | 78 +-- packages/setup/src/commands/setup.ts | 76 +-- packages/setup/src/services/validators.ts | 16 - packages/setup/src/templates.ts | 23 - turbo.json | 2 - 54 files changed, 390 insertions(+), 2334 deletions(-) delete mode 100644 apps/backend/src/rpc/middleware/auth-class.ts delete mode 100644 apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts delete mode 100644 apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts delete mode 100644 apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts delete mode 100644 apps/backend/src/services/session-manager.ts delete mode 100644 packages/auth/src/cache/index.ts delete mode 100644 packages/auth/src/cache/user-lookup-cache.ts delete mode 100644 packages/auth/src/cache/user-lookup-request.ts delete mode 100644 packages/auth/src/config.ts delete mode 100644 packages/auth/src/consumers/backend-auth.ts delete mode 100644 packages/auth/src/metrics.ts delete mode 100644 packages/auth/src/session/clerk-client.ts delete mode 100644 packages/auth/src/session/index.ts delete mode 100644 packages/auth/src/session/jwt-decoder.ts delete mode 100644 packages/auth/src/types.ts delete mode 100644 packages/backend-core/src/services/clerk-sync.ts delete mode 100644 packages/backend-core/src/services/index.ts delete mode 100644 packages/domain/src/session.ts 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..938561893 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -40,9 +40,7 @@ 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" @@ -81,7 +79,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" @@ -208,8 +205,6 @@ export const AppServicesLive = Layer.mergeAll( RepoLive, PolicyLive, MockDataGenerator.layer, - ClerkClient.layer, - ClerkSync.layer, ObjectStorage.layer, GitHub.GitHubAppJWTService.layer, GitHub.GitHubApiClient.layer, @@ -224,8 +219,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 +227,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/routes/webhooks.http.ts b/apps/backend/src/routes/webhooks.http.ts index 86c8eef8c..1f971753d 100644 --- a/apps/backend/src/routes/webhooks.http.ts +++ b/apps/backend/src/routes/webhooks.http.ts @@ -1,297 +1,244 @@ 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<string, unknown>, - 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/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..08667b57a 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<typeof SessionManager> type BotRepoShape = Context.Service.Shape<typeof BotRepo> type UserRepoShape = Context.Service.Shape<typeof UserRepo> type EffectSuccess<T> = T extends Effect.Effect<infer A, any, any> ? 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<typeof SessionManager>({ - authenticateWithBearer: () => Effect.succeed(currentUser), - }), - ) - type HazelSessionShape = Context.Service.Shape<typeof HazelSession> const makeHazelSessionLayer = (overrides: Partial<HazelSessionShape> = {}) => @@ -143,7 +134,6 @@ const USER_RECORD: UserRecord = { const runAuth = ( headers: Headers.Headers, overrides: { - sessionManager?: ReturnType<typeof makeSessionManagerLayer> hazelSession?: ReturnType<typeof makeHazelSessionLayer> botRepo?: ReturnType<typeof makeBotRepoLayer> userRepo?: ReturnType<typeof makeUserRepoLayer> @@ -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<BotRecord>())), @@ -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..c16564bb1 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -1,11 +1,10 @@ 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 +19,9 @@ async function hashToken(token: string): Promise<string> { 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 +45,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 +65,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 +93,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: <workosId>, status: "success", clerkUserId: <clerkId> } - * - * 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<void>) 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<string, Array<(typeof orgs)[number]>>() - 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<void>) 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<string, Enriched[]>() - 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<void>) diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index a8ed7a6c8..174c36a82 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,17 @@ 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/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts index 537bdd99c..077b624a4 100644 --- a/apps/backend/src/services/hazel-auth.test.ts +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -29,7 +29,6 @@ import { serviceShape } from "../test/effect-helpers" import { AuthorizationLive } from "./auth" import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./hazel-auth" import { HazelSession } from "./hazel-session" -import { SessionManager } from "./session-manager" const api = "http://localhost:3003" const app = "http://localhost:3000" @@ -64,14 +63,6 @@ const FakeGitHub = Layer.succeed( ), ) -/** Clerk is not involved: any Clerk path reaching it is a test failure. */ -const NoClerk = Layer.succeed( - SessionManager, - serviceShape<typeof SessionManager>({ - authenticateWithBearer: () => Effect.die(new Error("Clerk should not be consulted")), - }), -) - const successValue = { _: Symbol("success") } as SuccessValue const tagOf = (error: unknown) => @@ -158,7 +149,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 cd6c136d9..99d4797df 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<HazelSession>()("@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/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<OAuthBearerAuth>()("@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<OAuthBearerAuth>()("@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<OAuthBearerAuth>()("@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/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>()("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<string, number>() - 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/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/bun.lock b/bun.lock index a24bdeae8..e51368aa2 100644 --- a/bun.lock +++ b/bun.lock @@ -395,7 +395,6 @@ "name": "@hazel/auth", "version": "0.0.0", "dependencies": { - "@clerk/backend": "^3.3.0", "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", @@ -421,7 +420,6 @@ "name": "@hazel/backend-core", "version": "0.0.0", "dependencies": { - "@clerk/backend": "^3.3.0", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", @@ -551,7 +549,6 @@ "hazel-setup": "./src/index.ts", }, "dependencies": { - "@clerk/backend": "^3.3.0", "@effect/platform-bun": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/schema": "workspace:*", diff --git a/packages/auth/package.json b/packages/auth/package.json index aa0cf94ac..cdd972d15 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -6,7 +6,6 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./backend": "./src/consumers/backend-auth.ts", "./proxy": "./src/consumers/proxy-auth.ts", "./access-token": "./src/yielded/access-token.ts", "./desktop": "./src/yielded/desktop-client.ts", @@ -19,7 +18,6 @@ "generate-keys": "bun scripts/generate-keys.ts" }, "dependencies": { - "@clerk/backend": "^3.3.0", "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", diff --git a/packages/auth/src/cache/index.ts b/packages/auth/src/cache/index.ts deleted file mode 100644 index 4879ee47e..000000000 --- a/packages/auth/src/cache/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { UserLookupCache, USER_LOOKUP_CACHE_PREFIX, USER_LOOKUP_CACHE_TTL } from "./user-lookup-cache.ts" -export type { UserLookupResult } from "./user-lookup-request.ts" diff --git a/packages/auth/src/cache/user-lookup-cache.ts b/packages/auth/src/cache/user-lookup-cache.ts deleted file mode 100644 index 790b59d40..000000000 --- a/packages/auth/src/cache/user-lookup-cache.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Persistence } from "effect/persistence" -import type { UserId } from "@hazel/schema" -import { Context, Duration, Effect, Exit, Layer, Metric, Option } from "effect" -import { UserLookupCacheError } from "../errors.ts" -import { userLookupCacheHits, userLookupCacheMisses, userLookupCacheOperationLatency } from "../metrics.ts" -import { UserLookupCacheRequest, type UserLookupResult } from "./user-lookup-request.ts" - -export const USER_LOOKUP_CACHE_PREFIX = "auth:user-lookup" -export const USER_LOOKUP_CACHE_TTL = Duration.minutes(5) - -/** - * User lookup cache service using Persistence. - * Caches the mapping from externalId (Clerk user ID) → internal UserId. - */ -export class UserLookupCache extends Context.Service<UserLookupCache>()("@hazel/auth/UserLookupCache", { - make: Effect.gen(function* () { - const persistence = yield* Persistence.Persistence - - const store = yield* persistence.make({ - storeId: USER_LOOKUP_CACHE_PREFIX, - timeToLive: () => USER_LOOKUP_CACHE_TTL, - }) - - const get = ( - externalId: string, - ): Effect.Effect<Option.Option<UserLookupResult>, UserLookupCacheError> => - Effect.gen(function* () { - const startTime = Date.now() - - yield* Effect.annotateCurrentSpan("cache.system", "redis") - yield* Effect.annotateCurrentSpan("cache.name", USER_LOOKUP_CACHE_PREFIX) - yield* Effect.annotateCurrentSpan("cache.operation", "get") - - const request = new UserLookupCacheRequest({ externalId }) - - const cached = yield* store.get(request).pipe( - Effect.mapError( - (e) => - new UserLookupCacheError({ - message: "Failed to get user lookup from cache", - cause: e, - }), - ), - ) - - yield* Metric.update(userLookupCacheOperationLatency, Date.now() - startTime) - - if (cached === undefined) { - yield* Metric.update(userLookupCacheMisses, 1) - yield* Effect.annotateCurrentSpan("cache.result", "miss") - return Option.none<UserLookupResult>() - } - - if (Exit.isSuccess(cached)) { - yield* Metric.update(userLookupCacheHits, 1) - yield* Effect.annotateCurrentSpan("cache.result", "hit") - return Option.some(cached.value) - } - - yield* Metric.update(userLookupCacheMisses, 1) - yield* Effect.annotateCurrentSpan("cache.result", "miss") - yield* Effect.annotateCurrentSpan("cache.skip_reason", "failure_cached") - return Option.none<UserLookupResult>() - }).pipe(Effect.withSpan("UserLookupCache.get")) - - const set = (externalId: string, internalUserId: UserId): Effect.Effect<void, UserLookupCacheError> => - Effect.gen(function* () { - const startTime = Date.now() - - yield* Effect.annotateCurrentSpan("cache.system", "redis") - yield* Effect.annotateCurrentSpan("cache.name", USER_LOOKUP_CACHE_PREFIX) - yield* Effect.annotateCurrentSpan("cache.operation", "set") - - const request = new UserLookupCacheRequest({ externalId }) - const result: UserLookupResult = { internalUserId } - - yield* store.set(request, Exit.succeed(result)).pipe( - Effect.mapError( - (e) => - new UserLookupCacheError({ - message: "Failed to set user lookup in cache", - cause: e, - }), - ), - ) - - yield* Metric.update(userLookupCacheOperationLatency, Date.now() - startTime) - yield* Effect.annotateCurrentSpan( - "cache.item.ttl_ms", - Duration.toMillis(USER_LOOKUP_CACHE_TTL), - ) - - yield* Effect.logDebug(`Cached user lookup: ${externalId} -> ${internalUserId}`) - }).pipe(Effect.withSpan("UserLookupCache.set")) - - const invalidate = (externalId: string): Effect.Effect<void, UserLookupCacheError> => - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan("cache.system", "redis") - yield* Effect.annotateCurrentSpan("cache.name", USER_LOOKUP_CACHE_PREFIX) - yield* Effect.annotateCurrentSpan("cache.operation", "invalidate") - - const request = new UserLookupCacheRequest({ externalId }) - - yield* store.remove(request).pipe( - Effect.mapError( - (e) => - new UserLookupCacheError({ - message: "Failed to invalidate user lookup in cache", - cause: e, - }), - ), - ) - - yield* Effect.logDebug(`Invalidated cached user lookup: ${externalId}`) - }).pipe(Effect.withSpan("UserLookupCache.invalidate")) - - return { - get, - set, - invalidate, - } - }), -}) { - static readonly layer = Layer.effect(this, this.make) - - static Test = Layer.mock(this, { - get: (_externalId: string) => Effect.succeed(Option.none<UserLookupResult>()), - set: (_externalId: string, _internalUserId: UserId) => Effect.void, - invalidate: (_externalId: string) => Effect.void, - }) - - static TestWith = (options: { cachedResult?: UserLookupResult }) => - Layer.mock(UserLookupCache, { - get: (_externalId: string) => - Effect.succeed(options.cachedResult ? Option.some(options.cachedResult) : Option.none()), - set: (_externalId: string, _internalUserId: UserId) => Effect.void, - invalidate: (_externalId: string) => Effect.void, - }) -} diff --git a/packages/auth/src/cache/user-lookup-request.ts b/packages/auth/src/cache/user-lookup-request.ts deleted file mode 100644 index 6cad6f07f..000000000 --- a/packages/auth/src/cache/user-lookup-request.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { UserId } from "@hazel/schema" -import { Schema } from "effect" -import { Persistable } from "effect/persistence" -import { UserLookupCacheError } from "../errors.ts" - -/** - * Schema for cached user lookup result. - * Maps external user ID (Clerk user ID) → internal UserId. - */ -export const UserLookupResult = Schema.Struct({ - internalUserId: UserId, -}) - -export type UserLookupResult = typeof UserLookupResult.Type - -/** - * Request type for user lookup cache operations. - */ -export class UserLookupCacheRequest extends Persistable.Class<{ - payload: { - /** External user ID from the identity provider (Clerk user ID). */ - externalId: string - } -}>()("UserLookupCacheRequest", { - primaryKey: (payload) => payload.externalId, - success: UserLookupResult, - error: UserLookupCacheError, -}) {} diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts deleted file mode 100644 index 5575e3bab..000000000 --- a/packages/auth/src/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Context, Config, Effect, Layer } from "effect" - -/** - * Configuration for auth services (Clerk only). - */ -export interface AuthConfigShape { - /** Clerk secret key (sk_live_* / sk_test_*). */ - readonly clerkSecretKey: string - /** Clerk publishable key (pk_live_* / pk_test_*). */ - readonly clerkPublishableKey: string -} - -export class AuthConfig extends Context.Service<AuthConfig>()("@hazel/auth/AuthConfig", { - make: Effect.gen(function* () { - const clerkSecretKey = yield* Config.String("CLERK_SECRET_KEY") - const clerkPublishableKey = yield* Config.String("CLERK_PUBLISHABLE_KEY").pipe(Config.withDefault("")) - - return { - clerkSecretKey, - clerkPublishableKey, - } satisfies AuthConfigShape - }), -}) { - static readonly layer = Layer.effect(this, this.make) - - static Test = Layer.mock(this, { - clerkSecretKey: "sk_test_clerk_123", - clerkPublishableKey: "pk_test_clerk_123", - }) -} diff --git a/packages/auth/src/consumers/backend-auth.ts b/packages/auth/src/consumers/backend-auth.ts deleted file mode 100644 index c9906a6f5..000000000 --- a/packages/auth/src/consumers/backend-auth.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { verifyToken } from "@clerk/backend" -import { CurrentUser, InvalidBearerTokenError, InvalidJwtPayloadError, SessionLoadError } from "@hazel/domain" -import { User } from "@hazel/domain/models" -import { ClerkJwtClaims, type ClerkUserId, type UserId } from "@hazel/schema" -import { Context, Config, Effect, Layer, Option, Redacted, Schema } from "effect" -import { ClerkClient } from "../session/clerk-client.ts" - -type UserRow = { - id: UserId - email: string - firstName: string - lastName: string - avatarUrl: string | null - isOnboarded: boolean - timezone: string | null - settings: User.UserSettings | null -} - -/** - * Interface for the user repository methods needed by backend auth. - * Avoids circular dependencies by not depending on the full UserRepo. - */ -export interface UserRepoLike { - findByExternalId: ( - externalId: string, - ) => Effect.Effect<Option.Option<UserRow>, { _tag: "DatabaseError" }, any> - upsertClerkUser: (user: { - externalId: ClerkUserId - email: string - firstName: string - lastName: string - avatarUrl: string | null - userType: "user" | "machine" - settings: null - isOnboarded: boolean - timezone: string | null - deletedAt: null - }) => Effect.Effect<UserRow, { _tag: "DatabaseError" }, any> - update: (data: { - id: UserId - firstName?: string - lastName?: string - avatarUrl?: string | null - }) => Effect.Effect<UserRow, { _tag: "DatabaseError" } | { _tag: "SchemaError" }, any> -} - -export const decodeClerkJwtClaims = Schema.decodeUnknownEffect(ClerkJwtClaims) - -/** - * Backend authentication service. Verifies Clerk bearer JWTs and syncs the - * user into the DB if it's their first request. - */ -export class BackendAuth extends Context.Service<BackendAuth>()("@hazel/auth/BackendAuth", { - make: Effect.gen(function* () { - const clerk = yield* ClerkClient - const clerkSecretKey = yield* Config.Redacted("CLERK_SECRET_KEY") - - const normalizeAvatarUrl = (avatarUrl: string | null | undefined): string | null => - avatarUrl?.trim() ? avatarUrl : null - - const syncUserFromClerk = ( - userRepo: UserRepoLike, - clerkUserId: ClerkUserId, - email: string, - firstName: string | null, - lastName: string | null, - avatarUrl: string | null, - ) => - Effect.gen(function* () { - const userOption = yield* userRepo.findByExternalId(clerkUserId).pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new SessionLoadError({ - message: "Failed to query user by external ID", - detail: String(err), - }), - ), - }), - ) - - return yield* Option.match(userOption, { - onNone: () => - userRepo - .upsertClerkUser({ - externalId: clerkUserId, - email, - firstName: firstName || "", - lastName: lastName || "", - avatarUrl: normalizeAvatarUrl(avatarUrl), - userType: "user", - settings: null, - isOnboarded: false, - timezone: null, - deletedAt: null, - }) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new SessionLoadError({ - message: "Failed to create user", - detail: String(err), - }), - ), - }), - ), - onSome: (existing) => Effect.succeed(existing), - }) - }) - - const authenticate = (bearerToken: string, userRepo: UserRepoLike) => - Effect.gen(function* () { - const payload = yield* Effect.tryPromise({ - try: () => verifyToken(bearerToken, { secretKey: Redacted.value(clerkSecretKey) }), - catch: (error) => - new InvalidBearerTokenError({ - message: `Clerk JWT verification failed: ${error}`, - detail: String(error), - }), - }) - - const claims = yield* decodeClerkJwtClaims(payload).pipe( - Effect.mapError( - (error) => - new InvalidJwtPayloadError({ - message: "Invalid Clerk JWT claims", - detail: String(error), - }), - ), - ) - - const userOption = yield* userRepo.findByExternalId(claims.sub).pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new InvalidBearerTokenError({ - message: "Failed to query user", - detail: String(err), - }), - ), - }), - ) - - const user = yield* Option.match(userOption, { - onNone: () => - Effect.gen(function* () { - const clerkUser = yield* clerk.getUser(claims.sub) - const email = claims.email ?? clerkUser.emailAddresses[0]?.emailAddress ?? "" - return yield* syncUserFromClerk( - userRepo, - claims.sub, - email, - clerkUser.firstName, - clerkUser.lastName, - clerkUser.imageUrl ?? null, - ) - }), - onSome: (u) => Effect.succeed(u), - }) - - // Map Clerk org role ("org:admin" / "org:member") to our CurrentUser.role. - const currentUserRole: "admin" | "member" | "owner" = - claims.org_role === "org:admin" ? "admin" : "member" - - return new CurrentUser.Schema({ - id: user.id, - role: currentUserRole, - organizationId: undefined, - avatarUrl: user.avatarUrl ?? undefined, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - isOnboarded: user.isOnboarded, - timezone: user.timezone, - settings: user.settings, - }) - }) - - return { - authenticate, - syncUserFromClerk, - } - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(ClerkClient.layer)) - - /** Mock user ID - a valid UUID */ - static readonly mockUserId = "00000000-0000-4000-8000-000000000001" as UserId - - static mockUser = () => ({ - id: BackendAuth.mockUserId, - email: "test@example.com", - firstName: "Test", - lastName: "User", - avatarUrl: null, - isOnboarded: true, - timezone: "UTC" as string | null, - settings: null as User.UserSettings | null, - }) - - static mockCurrentUser = () => - new CurrentUser.Schema({ - id: BackendAuth.mockUserId, - role: "member", - organizationId: undefined, - avatarUrl: undefined, - firstName: "Test", - lastName: "User", - email: "test@example.com", - isOnboarded: true, - timezone: "UTC", - settings: null, - }) - - static Test = Layer.mock(this, { - authenticate: (_bearerToken: string, _userRepo: UserRepoLike) => - Effect.succeed(BackendAuth.mockCurrentUser()), - syncUserFromClerk: ( - _userRepo: UserRepoLike, - _clerkUserId: string, - _email: string, - _firstName: string | null, - _lastName: string | null, - _avatarUrl: string | null, - ) => Effect.succeed(BackendAuth.mockUser()), - }) - - static TestWith = (options: { - currentUser?: CurrentUser.Schema - shouldFail?: { authenticate?: Effect.Effect<never, any> } - }) => - Layer.mock(BackendAuth, { - authenticate: (_bearerToken: string, _userRepo: UserRepoLike) => - options.shouldFail?.authenticate ?? - Effect.succeed(options.currentUser ?? BackendAuth.mockCurrentUser()), - syncUserFromClerk: ( - _userRepo: UserRepoLike, - _clerkUserId: string, - _email: string, - _firstName: string | null, - _lastName: string | null, - _avatarUrl: string | null, - ) => Effect.succeed(BackendAuth.mockUser()), - }) -} - -export const BackendAuthLive = BackendAuth.layer diff --git a/packages/auth/src/errors.ts b/packages/auth/src/errors.ts index 24f07b24f..86539ad04 100644 --- a/packages/auth/src/errors.ts +++ b/packages/auth/src/errors.ts @@ -1,42 +1,5 @@ -import { Schema } from "effect" - -/** - * Error thrown when session cache operations fail - */ -export class SessionCacheError extends Schema.TaggedError<SessionCacheError>()("SessionCacheError", { - message: Schema.String, - cause: Schema.optional(Schema.Unknown), -}) {} - -/** - * Error thrown when user lookup cache operations fail - */ -export class UserLookupCacheError extends Schema.TaggedError<UserLookupCacheError>()("UserLookupCacheError", { - message: Schema.String, - cause: Schema.optional(Schema.Unknown), -}) {} - -/** Error thrown when fetching an organization from the identity provider fails. */ -export class OrganizationFetchError extends Schema.TaggedError<OrganizationFetchError>()( - "OrganizationFetchError", - { - message: Schema.String, - detail: Schema.optional(Schema.String), - }, -) {} - -/** Error thrown when creating an organization in the identity provider fails. */ -export class OrganizationCreateError extends Schema.TaggedError<OrganizationCreateError>()( - "OrganizationCreateError", - { - message: Schema.String, - detail: Schema.optional(Schema.String), - }, -) {} - // Re-export session errors from domain package for convenience export { - ClerkUserFetchError, InvalidBearerTokenError, InvalidJwtPayloadError, SessionAuthenticationError, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 303b0d68e..02d622043 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,10 +1,5 @@ -// Core types -export { type AuthenticatedUserContext } from "./types.ts" - // Errors -export { OrganizationCreateError, OrganizationFetchError, SessionCacheError } from "./errors.ts" export { - ClerkUserFetchError, InvalidBearerTokenError, InvalidJwtPayloadError, SessionAuthenticationError, @@ -14,12 +9,5 @@ export { SessionRefreshError, } from "./errors.ts" -// Configuration -export { AuthConfig, type AuthConfigShape } from "./config.ts" - -// Session -export { ClerkClient, decodeSessionJwt, getJwtExpiry } from "./session/index.ts" - // Consumers -export { BackendAuth, BackendAuthLive, type UserRepoLike } from "./consumers/backend-auth.ts" export { ProxyAuth, ProxyAuthenticationError, ProxyAuthLive } from "./consumers/proxy-auth.ts" diff --git a/packages/auth/src/metrics.ts b/packages/auth/src/metrics.ts deleted file mode 100644 index 8b1900e0d..000000000 --- a/packages/auth/src/metrics.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Auth Metrics - * - * Provides counters and histograms for monitoring auth performance. - */ -import { Metric } from "effect" - -// ============================================================================ -// Counters -// ============================================================================ - -/** Count of user lookup cache hits */ -export const userLookupCacheHits = Metric.counter("user_lookup.cache.hits") - -/** Count of user lookup cache misses */ -export const userLookupCacheMisses = Metric.counter("user_lookup.cache.misses") - -// ============================================================================ -// Histograms (latency in milliseconds) -// ============================================================================ - -/** User lookup cache operation latency (get/set) */ -export const userLookupCacheOperationLatency = Metric.histogram("user_lookup.cache.operation.latency_ms", { - boundaries: [1, 2, 5, 10, 25, 50], -}) diff --git a/packages/auth/src/session/clerk-client.ts b/packages/auth/src/session/clerk-client.ts deleted file mode 100644 index ce9671166..000000000 --- a/packages/auth/src/session/clerk-client.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createClerkClient, type Organization, type User } from "@clerk/backend" -import { ClerkUserFetchError } from "@hazel/domain" -import { ClerkOrganizationId, ClerkUserId } from "@hazel/schema" -import { Effect, Layer, Context } from "effect" -import { OrganizationCreateError, OrganizationFetchError } from "../errors.ts" -import { AuthConfig } from "../config.ts" - -/** - * Clerk Backend SDK wrapper with Effect integration. - * Provides type-safe access to Clerk Backend API operations. - */ -export class ClerkClient extends Context.Service<ClerkClient>()("@hazel/auth/ClerkClient", { - make: Effect.gen(function* () { - const config = yield* AuthConfig - const client = createClerkClient({ - secretKey: config.clerkSecretKey, - publishableKey: config.clerkPublishableKey, - }) - - const getUser = (userId: ClerkUserId): Effect.Effect<User, ClerkUserFetchError> => - Effect.tryPromise({ - try: () => client.users.getUser(userId), - catch: (error) => - new ClerkUserFetchError({ - message: "Failed to fetch user from Clerk", - detail: String(error), - }), - }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan("user.found", true)), - Effect.tapError(() => Effect.annotateCurrentSpan("user.found", false)), - Effect.withSpan("ClerkClient.getUser", { attributes: { "user.id": userId } }), - ) - - const getOrganization = ( - orgId: ClerkOrganizationId, - ): Effect.Effect<Organization, OrganizationFetchError> => - Effect.tryPromise({ - try: () => client.organizations.getOrganization({ organizationId: orgId }), - catch: (error) => - new OrganizationFetchError({ - message: "Failed to fetch organization from Clerk", - detail: String(error), - }), - }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan("org.found", true)), - Effect.tapError(() => Effect.annotateCurrentSpan("org.found", false)), - Effect.withSpan("ClerkClient.getOrganization", { attributes: { "org.id": orgId } }), - ) - - const createOrganization = (params: { - name: string - slug?: string | null - /** Clerk user id of the creator — becomes the org admin and fires the membership webhook. */ - createdBy?: string - }): Effect.Effect<Organization, OrganizationCreateError> => - Effect.tryPromise({ - try: () => - client.organizations.createOrganization({ - name: params.name, - ...(params.slug ? { slug: params.slug } : {}), - ...(params.createdBy ? { createdBy: params.createdBy } : {}), - }), - catch: (error) => - new OrganizationCreateError({ - message: "Failed to create organization in Clerk", - detail: String(error), - }), - }).pipe( - Effect.tap((org) => Effect.annotateCurrentSpan("org.id", org.id)), - Effect.withSpan("ClerkClient.createOrganization", { - attributes: { "org.slug": params.slug ?? undefined }, - }), - ) - - const deleteOrganization = (orgId: string): Effect.Effect<void, OrganizationCreateError> => - Effect.tryPromise({ - try: () => client.organizations.deleteOrganization(orgId), - catch: (error) => - new OrganizationCreateError({ - message: "Failed to delete organization in Clerk", - detail: String(error), - }), - }).pipe( - Effect.asVoid, - Effect.withSpan("ClerkClient.deleteOrganization", { attributes: { "org.id": orgId } }), - ) - - return { - getUser, - getOrganization, - createOrganization, - deleteOrganization, - /** Raw SDK client for operations not yet wrapped (createUser, updateUser, webhooks, etc.). */ - raw: client, - } - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(AuthConfig.layer)) - - /** Test layer with mock Clerk responses */ - static Test = Layer.mock(this, { - getUser: (userId: ClerkUserId) => - Effect.succeed({ - id: userId, - emailAddresses: [{ emailAddress: "test@example.com" } as User["emailAddresses"][number]], - firstName: "Test", - lastName: "User", - } as unknown as User), - getOrganization: (orgId: ClerkOrganizationId) => - Effect.succeed({ - id: orgId, - name: "Test Organization", - slug: "test-organization", - } as unknown as Organization), - createOrganization: (params: { name: string; slug?: string | null; createdBy?: string }) => - Effect.succeed({ - id: "org_test", - name: params.name, - slug: params.slug ?? "test-organization", - } as unknown as Organization), - deleteOrganization: (_orgId: string) => Effect.void, - raw: {} as ReturnType<typeof createClerkClient>, - }) -} diff --git a/packages/auth/src/session/index.ts b/packages/auth/src/session/index.ts deleted file mode 100644 index 2d5127599..000000000 --- a/packages/auth/src/session/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ClerkClient } from "./clerk-client.ts" -export { decodeSessionJwt, getJwtExpiry } from "./jwt-decoder.ts" diff --git a/packages/auth/src/session/jwt-decoder.ts b/packages/auth/src/session/jwt-decoder.ts deleted file mode 100644 index 034891309..000000000 --- a/packages/auth/src/session/jwt-decoder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { InvalidJwtPayloadError, JwtPayload } from "@hazel/domain" -import { Effect, Schema } from "effect" -import { decodeJwt } from "jose" - -/** - * Decode a JWT access token and validate its payload against the JwtPayload schema. - */ -export const decodeSessionJwt = (accessToken: string): Effect.Effect<JwtPayload, InvalidJwtPayloadError> => { - return Effect.gen(function* () { - const rawPayload = decodeJwt(accessToken) - - const payload = yield* Schema.decodeUnknownEffect(JwtPayload)(rawPayload).pipe( - Effect.mapError( - (error) => - new InvalidJwtPayloadError({ - message: "Invalid JWT payload", - detail: String(error), - }), - ), - ) - - return payload - }) -} - -/** - * Extract the session expiry time from a JWT access token. - * Returns the `exp` claim as a Unix timestamp. - */ -export const getJwtExpiry = (accessToken: string): number => { - const payload = decodeJwt(accessToken) - return (payload.exp as number) ?? Math.floor(Date.now() / 1000) + 3600 // Default to 1 hour if no exp -} diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts deleted file mode 100644 index 6343e0f3e..000000000 --- a/packages/auth/src/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { OrganizationId, UserId } from "@hazel/schema" - -/** - * Authenticated user context returned by electric-proxy auth. - * Includes the internal database user ID. - */ -export interface AuthenticatedUserContext { - /** External identity provider user ID (Clerk user ID, e.g. `user_2...`). */ - externalId: string - /** Internal database user UUID. */ - internalUserId: UserId - /** User email address. */ - email: string - /** Internal organization UUID if the JWT carries one. */ - organizationId?: OrganizationId - /** User role within the organization. */ - role?: string -} diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index e129557ba..127b67c71 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -6,14 +6,12 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./services": "./src/services/index.ts", "./repositories": "./src/repositories/index.ts" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/backend": "^3.3.0", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/schema": "workspace:*", diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 4cb0044f7..c08d5cd0c 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -46,5 +46,3 @@ export { TypingIndicatorRepo } from "./repositories/typing-indicator-repo" export { UserPresenceStatusRepo } from "./repositories/user-presence-status-repo" export { UserRepo } from "./repositories/user-repo" -// Services -export { ClerkSync } from "./services/clerk-sync" diff --git a/packages/backend-core/src/repositories/organization-member-repo.ts b/packages/backend-core/src/repositories/organization-member-repo.ts index 850df322f..79437c26c 100644 --- a/packages/backend-core/src/repositories/organization-member-repo.ts +++ b/packages/backend-core/src/repositories/organization-member-repo.ts @@ -24,7 +24,6 @@ export class OrganizationMemberRepo extends Context.Service<OrganizationMemberRe ) const db = yield* Database.Database - // Extended methods used by Clerk sync const findByOrgAndUser = (organizationId: OrganizationId, userId: UserId) => db.first( PG.from(schema.organizationMembersTable) diff --git a/packages/backend-core/src/repositories/organization-repo.ts b/packages/backend-core/src/repositories/organization-repo.ts index c93cfa35a..07176966e 100644 --- a/packages/backend-core/src/repositories/organization-repo.ts +++ b/packages/backend-core/src/repositories/organization-repo.ts @@ -1,7 +1,7 @@ import { Database, PG, Repository, schema } from "@hazel/db" import type { OrganizationId, UserId } from "@hazel/schema" import { Organization } from "@hazel/domain/models" -import { Context, Effect, Layer, Option } from "effect" +import { Context, Effect, Layer } from "effect" import { ChannelMemberRepo } from "./channel-member-repo" import { ChannelRepo } from "./channel-repo" @@ -27,79 +27,6 @@ export class OrganizationRepo extends Context.Service<OrganizationRepo>()("Organ .limit(1), ) - // Find an organization by the Clerk org id stashed in `settings.clerkOrganizationId`. - // Used by the Clerk webhook so post-migration events resolve to the canonical row - // even when slugs diverge between WorkOS-era and Clerk. - const findByClerkOrgId = (clerkOrgId: string) => - db.first( - PG.from(schema.organizationsTable) - .select() - .where(($) => [ - PG.jsonText($.settings, "clerkOrganizationId").eq(clerkOrgId), - $.deletedAt.isNull(), - ]) - .limit(1), - ) - - // Resolve a Clerk organization to a local row, creating or back-linking as needed. - // Lookup order is Clerk-ID first, slug second — this is the same order the Clerk - // webhook uses, and it must stay aligned to prevent duplicate "ghost" rows from - // being recreated by the session lazy-sync after a dedup pass. - const upsertFromClerk = (clerk: { - id: string - name: string - slug: string | null - imageUrl?: string | null - }) => - Effect.gen(function* () { - const byClerkId = yield* findByClerkOrgId(clerk.id).pipe(Effect.map(Option.getOrNull)) - const bySlug = - !byClerkId && clerk.slug - ? yield* findBySlug(clerk.slug).pipe(Effect.map(Option.getOrNull)) - : null - const existing = byClerkId ?? bySlug - - if (existing) { - const currentSettings = - (existing.settings as { clerkOrganizationId?: string } | null) ?? null - const needsClerkIdLink = currentSettings?.clerkOrganizationId !== clerk.id - const desiredLogoUrl = clerk.imageUrl ?? existing.logoUrl - const needsLogoUpdate = desiredLogoUrl !== existing.logoUrl - const needsNameUpdate = clerk.name !== existing.name - - if (needsClerkIdLink || needsLogoUpdate || needsNameUpdate) { - yield* baseRepo.update({ - id: existing.id, - ...(needsNameUpdate ? { name: clerk.name } : {}), - ...(needsLogoUpdate ? { logoUrl: desiredLogoUrl } : {}), - ...(needsClerkIdLink - ? { - settings: { - ...(currentSettings ?? {}), - clerkOrganizationId: clerk.id, - }, - } - : {}), - }) - } - return existing - } - - if (!clerk.slug) return null - - const inserted = yield* baseRepo - .insert({ - name: clerk.name, - slug: clerk.slug, - logoUrl: clerk.imageUrl ?? null, - isPublic: false, - settings: { clerkOrganizationId: clerk.id }, - deletedAt: null, - }) - .pipe(Effect.map((rows) => rows[0]!)) - return inserted - }) - const findBySlugIfPublic = (slug: string) => db.first( PG.from(schema.organizationsTable) @@ -156,12 +83,10 @@ export class OrganizationRepo extends Context.Service<OrganizationRepo>()("Organ return { ...baseRepo, findBySlug, - findByClerkOrgId, findBySlugIfPublic, findAllActive, softDelete, setupDefaultChannels, - upsertFromClerk, } }), }) { diff --git a/packages/backend-core/src/repositories/user-repo.ts b/packages/backend-core/src/repositories/user-repo.ts index 1072c6f71..bb490384d 100644 --- a/packages/backend-core/src/repositories/user-repo.ts +++ b/packages/backend-core/src/repositories/user-repo.ts @@ -1,6 +1,6 @@ import { Database, PG, Repository, schema } from "@hazel/db" -import type { ClerkUserId, UserId } from "@hazel/schema" +import type { UserId } from "@hazel/schema" import { User } from "@hazel/domain/models" import { Context, DateTime, Effect, Layer, type Schema } from "effect" @@ -66,11 +66,6 @@ export class UserRepo extends Context.Service<UserRepo>()("UserRepo", { ) .pipe(Effect.map((results) => results[0])) - const upsertClerkUser = ( - data: Omit<Schema.Schema.Type<typeof User.Insert>, "externalId"> & { externalId: ClerkUserId }, - options?: { syncAvatarUrl?: boolean }, - ) => upsertByExternalId(data, options) - const findAllActive = () => db.run( PG.from(schema.usersTable) @@ -85,28 +80,12 @@ export class UserRepo extends Context.Service<UserRepo>()("UserRepo", { .where(($) => [$.id.eq(id), $.deletedAt.isNull()]), ) - const softDeleteByExternalId = (externalId: string) => - db.run( - PG.update(schema.usersTable) - .set({ deletedAt: new Date() }) - .where(($) => [$.externalId.eq(externalId), $.deletedAt.isNull()]), - ) - - const softDeleteByClerkUserId = (clerkUserId: ClerkUserId) => softDeleteByExternalId(clerkUserId) - - const bulkUpsertByExternalId = (users: Schema.Schema.Type<typeof User.Insert>[]) => - Effect.forEach(users, (data) => upsertByExternalId(data), { concurrency: 10 }) - return { ...baseRepo, findByExternalId, upsertByExternalId, - upsertClerkUser, findAllActive, softDelete, - softDeleteByExternalId, - softDeleteByClerkUserId, - bulkUpsertByExternalId, } }), }) { diff --git a/packages/backend-core/src/services/clerk-sync.ts b/packages/backend-core/src/services/clerk-sync.ts deleted file mode 100644 index e3300bfe6..000000000 --- a/packages/backend-core/src/services/clerk-sync.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { WebhookEvent } from "@clerk/backend" -import type { DatabaseError } from "@hazel/db" -import { ClerkUserId, type OrganizationId, type UserId } from "@hazel/schema" -import { Effect, Layer, Option, Schema, Context } from "effect" -import { OrganizationMemberRepo } from "../repositories/organization-member-repo" -import { OrganizationRepo } from "../repositories/organization-repo" -import { UserRepo } from "../repositories/user-repo" - -/** - * Signals that a webhook event changed an organization membership. - * The route handler uses this to re-sync channel access (which lives in - * `apps/backend` and isn't reachable from this package). - */ -export type MembershipChange = { - readonly userId: UserId - readonly organizationId: OrganizationId -} - -/** - * Clerk webhook event processor. - * - * Handles: - * - `user.*` → upsert / soft-delete by Clerk user ID (externalId column). - * Email changes flow through `user.updated` (Clerk refires the - * whole user payload including `email_addresses`), so there's - * no separate email-sync case — the upsert picks the primary. - * - `organization.*` → upsert / soft-delete by slug (with Clerk org ID stashed in settings) - * - `organizationMembership.*` → maintain the org_members junction table - */ -export class ClerkSync extends Context.Service<ClerkSync>()("ClerkSync", { - make: Effect.gen(function* () { - const userRepo = yield* UserRepo - const orgRepo = yield* OrganizationRepo - const membershipRepo = yield* OrganizationMemberRepo - - const decodeClerkUserId = Schema.decodeUnknownSync(ClerkUserId) - - const normalizeAvatarUrl = (avatarUrl: string | null | undefined): string | null => - avatarUrl?.trim() ? avatarUrl : null - - // --- Users --- - - const handleUserUpsert = (data: unknown) => - Effect.gen(function* () { - const anyData = data as { - id: string - email_addresses?: Array<{ email_address: string; id: string }> - primary_email_address_id?: string | null - first_name?: string | null - last_name?: string | null - image_url?: string | null - } - - const primaryEmail = - anyData.email_addresses?.find((e) => e.id === anyData.primary_email_address_id) - ?.email_address ?? anyData.email_addresses?.[0]?.email_address - if (!primaryEmail) { - yield* Effect.logWarning(`Clerk user ${anyData.id} has no email — skipping upsert`) - return - } - - const existing = yield* userRepo - .findByExternalId(anyData.id) - .pipe(Effect.map(Option.getOrNull)) - - const firstName = existing - ? anyData.first_name || existing.firstName - : anyData.first_name || "" - const lastName = existing ? anyData.last_name || existing.lastName : anyData.last_name || "" - - yield* userRepo.upsertClerkUser({ - externalId: decodeClerkUserId(anyData.id), - email: primaryEmail, - firstName, - lastName, - avatarUrl: normalizeAvatarUrl(anyData.image_url), - userType: "user", - settings: null, - isOnboarded: existing?.isOnboarded ?? false, - timezone: existing?.timezone ?? null, - deletedAt: null, - }) - }).pipe(Effect.asVoid) - - const handleUserDeleted = (data: { id?: string }) => - Effect.gen(function* () { - if (!data.id) return - yield* userRepo.softDeleteByClerkUserId(decodeClerkUserId(data.id)) - }).pipe(Effect.asVoid) - - // --- Organizations --- - - const handleOrgUpsert = (data: unknown) => - Effect.gen(function* () { - const anyData = data as { - id: string - name: string - slug: string | null - image_url?: string | null - } - - const resolved = yield* orgRepo.upsertFromClerk({ - id: anyData.id, - name: anyData.name, - slug: anyData.slug, - imageUrl: anyData.image_url ?? null, - }) - - if (!resolved) { - yield* Effect.logWarning( - `Clerk org ${anyData.id} has no slug and no existing row — skipping insert`, - ) - } - }).pipe(Effect.asVoid) - - const handleOrgDeleted = (data: unknown) => - Effect.gen(function* () { - const anyData = data as { id?: string; slug?: string | null } - const byClerkId = anyData.id - ? yield* orgRepo.findByClerkOrgId(anyData.id).pipe(Effect.map(Option.getOrNull)) - : null - const bySlug = - !byClerkId && anyData.slug - ? yield* orgRepo.findBySlug(anyData.slug).pipe(Effect.map(Option.getOrNull)) - : null - const existing = byClerkId ?? bySlug - if (!existing) return - yield* orgRepo.deleteById(existing.id) - }).pipe(Effect.asVoid) - - // --- Organization memberships --- - - const roleFromClerk = (role: string | undefined | null): "admin" | "member" | "owner" => { - if (role === "org:admin" || role === "admin") return "admin" - return "member" - } - - const handleMembershipUpsert = ( - data: unknown, - ): Effect.Effect<Option.Option<MembershipChange>, DatabaseError> => - Effect.gen(function* () { - const anyData = data as { - public_user_data?: { user_id?: string } - organization?: { id?: string; slug?: string | null } - role?: string | null - } - const clerkUserId = anyData.public_user_data?.user_id - const clerkOrgId = anyData.organization?.id - const orgSlug = anyData.organization?.slug - if (!clerkUserId || (!clerkOrgId && !orgSlug)) return Option.none() - - const userOpt = yield* userRepo - .findByExternalId(clerkUserId) - .pipe(Effect.map(Option.getOrNull)) - const byClerkId = clerkOrgId - ? yield* orgRepo.findByClerkOrgId(clerkOrgId).pipe(Effect.map(Option.getOrNull)) - : null - const bySlug = - !byClerkId && orgSlug - ? yield* orgRepo.findBySlug(orgSlug).pipe(Effect.map(Option.getOrNull)) - : null - const orgOpt = byClerkId ?? bySlug - if (!userOpt || !orgOpt) { - yield* Effect.logWarning( - `Skipping membership upsert — user or org not found (user=${clerkUserId}, org=${clerkOrgId ?? orgSlug})`, - ) - return Option.none() - } - - // Clerk has no "owner" concept — it's a Hazel-only elevated role — so a Clerk - // membership event (which can only carry admin/member) must never downgrade an - // existing owner. - const existingMembership = yield* membershipRepo - .findByOrgAndUser(orgOpt.id, userOpt.id) - .pipe(Effect.map(Option.getOrNull)) - const role = existingMembership?.role === "owner" ? "owner" : roleFromClerk(anyData.role) - - yield* membershipRepo.upsertByOrgAndUser({ - organizationId: orgOpt.id, - userId: userOpt.id, - role, - nickname: undefined, - joinedAt: new Date(), - invitedBy: null, - deletedAt: null, - }) - - return Option.some({ userId: userOpt.id, organizationId: orgOpt.id }) - }) - - const handleMembershipRemoved = ( - data: unknown, - ): Effect.Effect<Option.Option<MembershipChange>, DatabaseError> => - Effect.gen(function* () { - const anyData = data as { - public_user_data?: { user_id?: string } - organization?: { id?: string; slug?: string | null } - } - const clerkUserId = anyData.public_user_data?.user_id - const clerkOrgId = anyData.organization?.id - const orgSlug = anyData.organization?.slug - if (!clerkUserId || (!clerkOrgId && !orgSlug)) return Option.none() - - const userOpt = yield* userRepo - .findByExternalId(clerkUserId) - .pipe(Effect.map(Option.getOrNull)) - const byClerkId = clerkOrgId - ? yield* orgRepo.findByClerkOrgId(clerkOrgId).pipe(Effect.map(Option.getOrNull)) - : null - const bySlug = - !byClerkId && orgSlug - ? yield* orgRepo.findBySlug(orgSlug).pipe(Effect.map(Option.getOrNull)) - : null - const orgOpt = byClerkId ?? bySlug - if (!userOpt || !orgOpt) return Option.none() - - yield* membershipRepo.softDeleteByOrgAndUser(orgOpt.id, userOpt.id) - - return Option.some({ userId: userOpt.id, organizationId: orgOpt.id }) - }) - - /** - * Process a verified Clerk webhook event. Returns `{ success, error?, membershipChange? }`. - * When membership events are processed, `membershipChange` carries the affected Hazel - * user + org IDs so the caller can re-sync channel access (which lives outside this package). - */ - const processWebhookEvent = (event: WebhookEvent) => - Effect.gen(function* () { - yield* Effect.logInfo(`Processing Clerk webhook: ${event.type}`, { - type: event.type, - }) - - let membershipChange: MembershipChange | undefined - let membershipRemoved = false - - switch (event.type) { - case "user.created": - case "user.updated": - yield* handleUserUpsert(event.data) - break - case "user.deleted": - yield* handleUserDeleted(event.data as { id?: string }) - break - case "organization.created": - case "organization.updated": - yield* handleOrgUpsert(event.data) - break - case "organization.deleted": - yield* handleOrgDeleted(event.data) - break - case "organizationMembership.created": - case "organizationMembership.updated": { - const result = yield* handleMembershipUpsert(event.data) - if (Option.isSome(result)) membershipChange = result.value - break - } - case "organizationMembership.deleted": { - const result = yield* handleMembershipRemoved(event.data) - if (Option.isSome(result)) { - membershipChange = result.value - membershipRemoved = true - } - break - } - default: - yield* Effect.logDebug(`Ignoring Clerk event type: ${event.type}`) - } - - return { - success: true as const, - membershipChange, - membershipRemoved, - } - }).pipe( - Effect.catch((err: unknown) => - Effect.succeed({ - success: false as const, - error: err instanceof Error ? err.message : String(err), - }), - ), - ) - - return { - processWebhookEvent, - handleUserUpsert, - handleUserDeleted, - handleOrgUpsert, - handleOrgDeleted, - handleMembershipUpsert, - handleMembershipRemoved, - } - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe( - Layer.provide(UserRepo.layer), - Layer.provide(OrganizationRepo.layer), - Layer.provide(OrganizationMemberRepo.layer), - ) -} diff --git a/packages/backend-core/src/services/index.ts b/packages/backend-core/src/services/index.ts deleted file mode 100644 index 840e2aba0..000000000 --- a/packages/backend-core/src/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ClerkSync } from "./clerk-sync" diff --git a/packages/domain/src/current-user.ts b/packages/domain/src/current-user.ts index b986e6043..05c1c9d18 100644 --- a/packages/domain/src/current-user.ts +++ b/packages/domain/src/current-user.ts @@ -4,7 +4,6 @@ import { UnauthorizedError } from "./errors" import { OrganizationId, UserId } from "@hazel/schema" import { User } from "./models" import { - ClerkUserFetchError, InvalidBearerTokenError, InvalidJwtPayloadError, SessionAuthenticationError, @@ -38,7 +37,6 @@ const AuthFailure = S.Union([ SessionRefreshError, SessionExpiredError, InvalidBearerTokenError, - ClerkUserFetchError, ]) export class Authorization extends HttpApiMiddleware.Service< diff --git a/packages/domain/src/http/api-v1/integrations.ts b/packages/domain/src/http/api-v1/integrations.ts index 2e291fd02..c2b9505f4 100644 --- a/packages/domain/src/http/api-v1/integrations.ts +++ b/packages/domain/src/http/api-v1/integrations.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" import { AvatarUrl, ChannelId, ChannelWebhookId, OrganizationId } from "@hazel/schema" import { InternalServerError, UnauthorizedError } from "../../errors" +import { SessionLoadError } from "../../session-errors" import { ChannelType } from "../../models/channel-model" /** @@ -12,7 +13,9 @@ import { ChannelType } from "../../models/channel-model" * the integration delivery path (the existing /webhooks/incoming/...) takes * care of formatting and posting. * - * Auth: Bearer access tokens issued by Hazel's Clerk OAuth Applications. + * Auth: Bearer access tokens issued by Hazel's Clerk OAuth Applications (the last Clerk + * consumer; see `OAuthBearerAuth` in the backend). `SessionLoadError` (503) means the token + * could not be checked right now, not that it is invalid. */ // ============ MODELS ============ @@ -96,7 +99,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("api-v1-integrations .add( HttpApiEndpoint.get("listOrganizations", "/organizations", { success: ApiV1OrganizationsListResponse, - error: [UnauthorizedError, InternalServerError], + error: [UnauthorizedError, SessionLoadError, InternalServerError], }) .annotateMerge( OpenApi.annotations({ @@ -113,6 +116,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("api-v1-integrations success: ApiV1ChannelsListResponse, error: [ UnauthorizedError, + SessionLoadError, ApiV1OrganizationNotFoundError, InternalServerError, ], @@ -131,6 +135,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("api-v1-integrations success: ApiV1ChannelWebhookCreatedResponse, error: [ UnauthorizedError, + SessionLoadError, ApiV1ChannelNotFoundError, InternalServerError, ], diff --git a/packages/domain/src/http/webhooks.ts b/packages/domain/src/http/webhooks.ts index a3785dbdc..d961176a5 100644 --- a/packages/domain/src/http/webhooks.ts +++ b/packages/domain/src/http/webhooks.ts @@ -1,23 +1,8 @@ -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" -import { InternalServerError, WorkflowInitializationError } from "../errors" +import { InternalServerError, WorkflowInitializationError, WorkflowServiceUnavailableError } from "../errors" import { RequiredScopes } from "../scopes/required-scopes" -export class WebhookResponse extends Schema.Class<WebhookResponse>("WebhookResponse")({ - success: Schema.Boolean, - message: Schema.optional(Schema.String), -}) {} - -export class InvalidWebhookSignature extends Schema.TaggedError<InvalidWebhookSignature>( - "InvalidWebhookSignature", -)( - "InvalidWebhookSignature", - { - message: Schema.String, - }, - { httpApiStatus: 401 }, -) {} - // GitHub Webhook Types export class GitHubWebhookResponse extends Schema.Class<GitHubWebhookResponse>("GitHubWebhookResponse")({ processed: Schema.Boolean, @@ -34,27 +19,29 @@ export class InvalidGitHubWebhookSignature extends Schema.TaggedError<InvalidGit { httpApiStatus: 401 }, ) {} +/** The delivery is not JSON, or a supported event lacks the fields it must carry. */ +export class InvalidGitHubWebhookPayload extends Schema.TaggedError<InvalidGitHubWebhookPayload>( + "InvalidGitHubWebhookPayload", +)( + "InvalidGitHubWebhookPayload", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + export class WebhookGroup extends HttpApiGroup.make("webhooks") - .add( - HttpApiEndpoint.post("clerk", "/clerk", { - payload: Schema.Unknown, - success: WebhookResponse, - error: [InvalidWebhookSignature, InternalServerError], - }) - .annotateMerge( - OpenApi.annotations({ - title: "Clerk Webhook", - description: "Receive and process Clerk webhook events (Svix-signed)", - summary: "Process Clerk webhook events", - }), - ) - .annotate(RequiredScopes, []), - ) .add( HttpApiEndpoint.post("github", "/github", { payload: Schema.Unknown, success: GitHubWebhookResponse, - error: [InvalidGitHubWebhookSignature, InternalServerError, WorkflowInitializationError], + error: [ + InvalidGitHubWebhookSignature, + InvalidGitHubWebhookPayload, + InternalServerError, + WorkflowInitializationError, + WorkflowServiceUnavailableError, + ], }) .annotateMerge( OpenApi.annotations({ diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 6dc58d1a9..696f611b5 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -20,5 +20,4 @@ export * as Scopes from "./scopes" export * from "./rate-limit-errors" // RPC contracts export * as Rpc from "./rpc" -export * from "./session" export * from "./session-errors" diff --git a/packages/domain/src/rpc/middleware.ts b/packages/domain/src/rpc/middleware.ts index 3fe5037ae..d3185deef 100644 --- a/packages/domain/src/rpc/middleware.ts +++ b/packages/domain/src/rpc/middleware.ts @@ -10,7 +10,6 @@ import { Schema as S } from "effect" import * as CurrentUser from "../current-user" import { UnauthorizedError } from "../errors" import { - ClerkUserFetchError, InvalidBearerTokenError, InvalidJwtPayloadError, SessionAuthenticationError, @@ -23,8 +22,8 @@ import { /** * Authentication middleware that provides CurrentUser context to RPC handlers. * - * Verifies a Clerk bearer JWT from the `Authorization` header and populates - * the `CurrentUser` Effect context for the handler. + * Resolves the Hazel session (cookie or bearer credential) or a bot token and + * populates the `CurrentUser` Effect context for the handler. */ const AuthFailure = S.Union([ UnauthorizedError, @@ -35,7 +34,6 @@ const AuthFailure = S.Union([ SessionRefreshError, SessionExpiredError, InvalidBearerTokenError, - ClerkUserFetchError, ]) export class AuthMiddleware extends RpcMiddleware.Service< diff --git a/packages/domain/src/session-errors.ts b/packages/domain/src/session-errors.ts index 3e2dde4d4..d2f5e5935 100644 --- a/packages/domain/src/session-errors.ts +++ b/packages/domain/src/session-errors.ts @@ -73,11 +73,3 @@ export class SessionRefreshError extends Schema.TaggedError<SessionRefreshError> { httpApiStatus: 401 }, ) {} -export class ClerkUserFetchError extends Schema.TaggedError<ClerkUserFetchError>("ClerkUserFetchError")( - "ClerkUserFetchError", - { - message: Schema.String, - detail: Schema.String, - }, - { httpApiStatus: 503 }, -) {} diff --git a/packages/domain/src/session.ts b/packages/domain/src/session.ts deleted file mode 100644 index 7e6f88aed..000000000 --- a/packages/domain/src/session.ts +++ /dev/null @@ -1 +0,0 @@ -export { ClerkJwtClaims as JwtPayload } from "@hazel/schema" diff --git a/packages/schema/src/clerk.ts b/packages/schema/src/clerk.ts index 2497b58b7..8a8cf0cc8 100644 --- a/packages/schema/src/clerk.ts +++ b/packages/schema/src/clerk.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" +/** The subject of a Clerk-issued OAuth access token (Maple's integration, the last Clerk consumer). */ export const ClerkUserId = Schema.Trimmed.check(Schema.isNonEmpty()) .pipe(Schema.brand("@HazelChat/ClerkUserId")) .annotate({ @@ -7,42 +8,3 @@ export const ClerkUserId = Schema.Trimmed.check(Schema.isNonEmpty()) title: "Clerk User ID", }) export type ClerkUserId = Schema.Schema.Type<typeof ClerkUserId> - -export const ClerkOrganizationId = Schema.Trimmed.check(Schema.isNonEmpty()) - .pipe(Schema.brand("@HazelChat/ClerkOrganizationId")) - .annotate({ - description: "A Clerk organization identifier (org_…)", - title: "Clerk Organization ID", - }) -export type ClerkOrganizationId = Schema.Schema.Type<typeof ClerkOrganizationId> - -export const ClerkSessionId = Schema.Trimmed.check(Schema.isNonEmpty()) - .pipe(Schema.brand("@HazelChat/ClerkSessionId")) - .annotate({ - description: "A Clerk session identifier (sess_…)", - title: "Clerk Session ID", - }) -export type ClerkSessionId = Schema.Schema.Type<typeof ClerkSessionId> - -export const ClerkInvitationId = Schema.Trimmed.check(Schema.isNonEmpty()) - .pipe(Schema.brand("@HazelChat/ClerkInvitationId")) - .annotate({ - description: "A Clerk invitation identifier", - title: "Clerk Invitation ID", - }) -export type ClerkInvitationId = Schema.Schema.Type<typeof ClerkInvitationId> - -export const ClerkOrganizationRole = Schema.Literals(["org:admin", "org:member"]) -export type ClerkOrganizationRole = Schema.Schema.Type<typeof ClerkOrganizationRole> - -export class ClerkJwtClaims extends Schema.Class<ClerkJwtClaims>("ClerkJwtClaims")({ - sub: ClerkUserId, - sid: Schema.optional(ClerkSessionId), - org_id: Schema.optional(ClerkOrganizationId), - org_role: Schema.optional(Schema.String), - org_slug: Schema.optional(Schema.String), - azp: Schema.optional(Schema.String), - email: Schema.optional(Schema.String), - exp: Schema.optional(Schema.Number), - iat: Schema.optional(Schema.Number), -}) {} diff --git a/packages/setup/package.json b/packages/setup/package.json index b457bd3c1..5e79ee885 100644 --- a/packages/setup/package.json +++ b/packages/setup/package.json @@ -11,7 +11,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/backend": "^3.3.0", "@effect/platform-bun": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/schema": "workspace:*", diff --git a/packages/setup/src/commands/env.ts b/packages/setup/src/commands/env.ts index d4dd9cca9..7bf153a5c 100644 --- a/packages/setup/src/commands/env.ts +++ b/packages/setup/src/commands/env.ts @@ -89,69 +89,18 @@ export const envCommand = Command.make( } } - // Step 2: Clerk setup - yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 2: Clerk Authentication \u2500\u2500\u2500")) - yield* Console.log("Clerk provides user authentication.") - if (!existingConfig.clerkSecretKey) { - yield* Console.log(`Create a free account at ${pc.cyan("https://dashboard.clerk.com")}\n`) - yield* Console.log(pc.dim("1. Create a new application")) - yield* Console.log(pc.dim("2. Go to API Keys \u2192 copy the Publishable key (pk_test_...)")) - yield* Console.log(pc.dim("3. Go to API Keys \u2192 copy the Secret key (sk_test_...)")) - yield* Console.log( - pc.dim( - "4. Go to Webhooks \u2192 add endpoint http://localhost:3003/webhooks/clerk \u2192 copy signing secret (whsec_...)", - ), - ) - yield* Console.log( - pc.dim("5. Enable Organizations + Slugs in the dashboard (required by this app)\n"), - ) - } else { - yield* Console.log( - pc.dim("(Using existing credentials - press Enter to keep or type new value)\n"), - ) - } - - const clerkSecretKey = yield* promptWithExisting({ - key: "CLERK_SECRET_KEY", - message: "Enter your Clerk Secret Key", - envResult, - validate: (s) => - s.startsWith("sk_") ? Effect.succeed(s) : Effect.fail("Must start with sk_"), - isSecret: true, - }) - - const clerkPublishableKey = yield* promptWithExisting({ - key: "CLERK_PUBLISHABLE_KEY", - message: "Enter your Clerk Publishable Key", - envResult, - validate: (s) => - s.startsWith("pk_") ? Effect.succeed(s) : Effect.fail("Must start with pk_"), - }) - - const clerkWebhookSecret = yield* promptWithExisting({ - key: "CLERK_WEBHOOK_SECRET", - message: "Enter your Clerk Webhook Signing Secret", - envResult, - validate: (s) => - s.startsWith("whsec_") ? Effect.succeed(s) : Effect.fail("Must start with whsec_"), - isSecret: true, - }) - - // Validate Clerk credentials - if (!skipValidation) { - yield* Console.log(pc.dim("\nValidating Clerk credentials...")) - const validator = yield* CredentialValidator - const result = yield* validator - .validateClerk(clerkSecretKey, clerkPublishableKey) - .pipe(Effect.result) - - if (result._tag === "Failure") { - yield* Console.log(pc.red(`\u274C Clerk validation failed: ${result.failure.message}`)) - yield* Console.log(pc.dim("Please check your credentials and try again.")) - return - } - yield* Console.log(pc.green("\u2713") + " Clerk credentials valid\n") - } + // Step 2: Sign-in + yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 2: Sign-in \u2500\u2500\u2500")) + yield* Console.log( + pc.dim( + "Hazel sign-in is off until AUTH_GITHUB_CLIENT_ID / AUTH_GITHUB_CLIENT_SECRET are set in apps/backend/.env.", + ), + ) + yield* Console.log( + pc.dim( + `Generate its keys with ${pc.cyan("bun run --cwd packages/auth generate-keys")} and add them there too.\n`, + ), + ) // Step 3: Secrets - reuse existing or generate new yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 3: Secrets \u2500\u2500\u2500")) @@ -381,9 +330,6 @@ export const envCommand = Command.make( } const config: Config = { - clerkSecretKey, - clerkPublishableKey, - clerkWebhookSecret, secrets: generatedSecrets, s3: s3Config, s3PublicUrl: s3Config?.publicUrl, diff --git a/packages/setup/src/commands/setup.ts b/packages/setup/src/commands/setup.ts index db841c3fb..6af03c5e8 100644 --- a/packages/setup/src/commands/setup.ts +++ b/packages/setup/src/commands/setup.ts @@ -204,67 +204,18 @@ export const setupCommand = Command.make( } } - // Step 2: Clerk setup - yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 2: Clerk Authentication \u2500\u2500\u2500")) - yield* Console.log("Clerk provides user authentication.") - if (!existingConfig.clerkSecretKey) { - yield* Console.log(`Create a free account at ${pc.cyan("https://dashboard.clerk.com")}\n`) - yield* Console.log(pc.dim("1. Create a new application")) - yield* Console.log( - pc.dim("2. API Keys \u2192 copy Publishable key (pk_test_...) + Secret (sk_test_...)"), - ) - yield* Console.log( - pc.dim( - "3. Webhooks \u2192 endpoint http://localhost:3003/webhooks/clerk \u2192 copy signing secret (whsec_...)", - ), - ) - yield* Console.log(pc.dim("4. Enable Organizations + Slugs in the dashboard\n")) - } else { - yield* Console.log( - pc.dim("(Using existing credentials - press Enter to keep or type new value)\n"), - ) - } - - const clerkSecretKey = yield* promptWithExisting({ - key: "CLERK_SECRET_KEY", - message: "Enter your Clerk Secret Key", - envResult, - validate: (s) => - s.startsWith("sk_") ? Effect.succeed(s) : Effect.fail("Must start with sk_"), - isSecret: true, - }) - - const clerkPublishableKey = yield* promptWithExisting({ - key: "CLERK_PUBLISHABLE_KEY", - message: "Enter your Clerk Publishable Key", - envResult, - validate: (s) => - s.startsWith("pk_") ? Effect.succeed(s) : Effect.fail("Must start with pk_"), - }) - - const clerkWebhookSecret = yield* promptWithExisting({ - key: "CLERK_WEBHOOK_SECRET", - message: "Enter your Clerk Webhook Signing Secret", - envResult, - validate: (s) => - s.startsWith("whsec_") ? Effect.succeed(s) : Effect.fail("Must start with whsec_"), - isSecret: true, - }) - - if (!skipValidation) { - yield* Console.log(pc.dim("\nValidating Clerk credentials...")) - const validator = yield* CredentialValidator - const result = yield* validator - .validateClerk(clerkSecretKey, clerkPublishableKey) - .pipe(Effect.result) - - if (result._tag === "Failure") { - yield* Console.log(pc.red(`\u274C Clerk validation failed: ${result.failure.message}`)) - yield* Console.log(pc.dim("Please check your credentials and try again.")) - return - } - yield* Console.log(pc.green("\u2713") + " Clerk credentials valid\n") - } + // Step 2: Sign-in + yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 2: Sign-in \u2500\u2500\u2500")) + yield* Console.log( + pc.dim( + "Hazel sign-in is off until AUTH_GITHUB_CLIENT_ID / AUTH_GITHUB_CLIENT_SECRET are set in apps/backend/.env.", + ), + ) + yield* Console.log( + pc.dim( + `Generate its keys with ${pc.cyan("bun run --cwd packages/auth generate-keys")} and add them there too.\n`, + ), + ) // Step 3: Secrets - reuse existing or generate new yield* Console.log(pc.cyan("\u2500\u2500\u2500 Step 3: Secrets \u2500\u2500\u2500")) @@ -521,9 +472,6 @@ export const setupCommand = Command.make( } const config: Config = { - clerkSecretKey, - clerkPublishableKey, - clerkWebhookSecret, secrets: generatedSecrets, s3: s3Config, s3PublicUrl: s3Config.publicUrl, diff --git a/packages/setup/src/services/validators.ts b/packages/setup/src/services/validators.ts index f4a2a7566..2b6233ee7 100644 --- a/packages/setup/src/services/validators.ts +++ b/packages/setup/src/services/validators.ts @@ -1,4 +1,3 @@ -import { createClerkClient } from "@clerk/backend" import { Context, Data, Effect, Layer } from "effect" import { SQL } from "bun" @@ -9,21 +8,6 @@ export class ValidationError extends Data.TaggedError("ValidationError")<{ export class CredentialValidator extends Context.Service<CredentialValidator>()("CredentialValidator", { make: Effect.succeed({ - validateClerk: (secretKey: string, publishableKey: string) => - Effect.tryPromise({ - try: async () => { - const clerk = createClerkClient({ secretKey, publishableKey }) - // Cheap auth-check call: list users with limit=1. - await clerk.users.getUserList({ limit: 1 }) - return { valid: true as const } - }, - catch: (error) => - new ValidationError({ - service: "Clerk", - message: error instanceof Error ? error.message : String(error), - }), - }), - validateDatabase: (url: string) => Effect.tryPromise({ try: async () => { diff --git a/packages/setup/src/templates.ts b/packages/setup/src/templates.ts index 6b3becb20..9d5d04f38 100644 --- a/packages/setup/src/templates.ts +++ b/packages/setup/src/templates.ts @@ -16,9 +16,6 @@ export interface EnvValue { /** Existing configuration extracted from .env files */ export interface ExistingConfig { - clerkSecretKey?: EnvValue - clerkPublishableKey?: EnvValue - clerkWebhookSecret?: EnvValue encryptionKey?: EnvValue linear?: { clientId: EnvValue @@ -47,16 +44,6 @@ const getEnvValue = (result: EnvReadResult, key: string): EnvValue | undefined = export const extractExistingConfig = (result: EnvReadResult): ExistingConfig => { const config: ExistingConfig = {} - // Clerk - const clerkSecretKey = getEnvValue(result, "CLERK_SECRET_KEY") - if (clerkSecretKey) config.clerkSecretKey = clerkSecretKey - - const clerkPublishableKey = getEnvValue(result, "CLERK_PUBLISHABLE_KEY") - if (clerkPublishableKey) config.clerkPublishableKey = clerkPublishableKey - - const clerkWebhookSecret = getEnvValue(result, "CLERK_WEBHOOK_SECRET") - if (clerkWebhookSecret) config.clerkWebhookSecret = clerkWebhookSecret - const encryptionKey = getEnvValue(result, "INTEGRATION_ENCRYPTION_KEY") if (encryptionKey) config.encryptionKey = encryptionKey @@ -122,9 +109,6 @@ export const getLocalMinioConfig = (): S3Config => ({ }) export interface Config { - clerkSecretKey: string - clerkPublishableKey: string - clerkWebhookSecret: string secrets: { encryptionKey: string } @@ -149,7 +133,6 @@ export const ENV_TEMPLATES = { VITE_BACKEND_URL: "https://localhost:3004", VITE_CLUSTER_URL: "http://localhost:3020", VITE_ELECTRIC_URL: "https://localhost:5133/v1/shape", - VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, VITE_R2_PUBLIC_URL: config.s3PublicUrl ?? "", }), @@ -173,11 +156,6 @@ export const ENV_TEMPLATES = { // Electric ELECTRIC_URL: "http://localhost:3333", - // Clerk - CLERK_SECRET_KEY: config.clerkSecretKey, - CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, - CLERK_WEBHOOK_SECRET: config.clerkWebhookSecret, - // Encryption INTEGRATION_ENCRYPTION_KEY: config.secrets.encryptionKey, INTEGRATION_ENCRYPTION_KEY_VERSION: "1", @@ -221,7 +199,6 @@ export const ENV_TEMPLATES = { DATABASE_URL: "postgresql://user:password@localhost:5432/app", IS_DEV: "true", ELECTRIC_URL: "http://localhost:3333", - CLERK_SECRET_KEY: config.clerkSecretKey, ALLOWED_ORIGIN: "http://localhost:3000", }), 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", From 36f2ece3276e0080c76d68ae4f027aaa6c06dfd4 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:53:32 +0200 Subject: [PATCH 06/52] fix(backend): keep failure causes server-side and stop policy DB errors becoming 401s InternalServerError, WorkflowInitializationError and WorkflowServiceUnavailableError no longer have a serialized `cause`: the constructor keeps it as the native, non-enumerable Error.cause, so it reaches logs and traces but never RPC/HTTP clients. withRemapDbErrors passes the DatabaseError/SchemaError as that cause (generics renamed to A, E, R). refailUnauthorized turned every policy failure, database errors included, into UnauthorizedError. Infrastructure failures now become InternalServerError. OrgResolver no longer orDie's its repository lookups, so the DatabaseError stays typed instead of becoming an untyped defect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/backend/src/lib/policy-utils.test.ts | 19 +++- .../backend/src/services/org-resolver.test.ts | 32 +++++++ apps/backend/src/services/org-resolver.ts | 19 ++-- packages/domain/src/error-utils.ts | 64 +++++++------ packages/domain/src/errors.ts | 89 ++++++++++++++----- 5 files changed, 163 insertions(+), 60 deletions(-) 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/services/org-resolver.test.ts b/apps/backend/src/services/org-resolver.test.ts index 6278fdc9f..654b99d69 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,37 @@ 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<typeof OrganizationMemberRepo>({ + 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..3d6490192 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>()("OrgResolver", { make: Effect.gen(function* () { @@ -45,7 +49,6 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { const actor = yield* CurrentUser.Context const member = yield* organizationMemberRepo .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -71,7 +74,6 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { const actor = yield* CurrentUser.Context const member = yield* organizationMemberRepo .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -95,7 +97,6 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { const actor = yield* CurrentUser.Context const member = yield* organizationMemberRepo .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) if (Option.isNone(member) || member.value.role !== "owner") { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -117,7 +118,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("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 +139,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("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}` }), @@ -148,7 +149,6 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { const ch = channel.value const orgMember = yield* organizationMemberRepo .findByOrgAndUser(ch.organizationId, actor.id) - .pipe(Effect.orDie) if (Option.isNone(orgMember)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -177,7 +177,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("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 +194,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { channel: { type: string; parentChannelId?: string | null; id: string; organizationId: string }, actor: CurrentUser.Schema, orgMember: { role: string }, - ): Effect.Effect<boolean> => + ): Effect.Effect<boolean, DatabaseError> => Effect.gen(function* () { if (channel.type === "public") { return true @@ -206,14 +206,12 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { } const membership = yield* channelMemberRepo .findByChannelAndUser(channel.id as ChannelId, actor.id) - .pipe(Effect.orDie) 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) return Option.isSome(membership) } @@ -223,7 +221,6 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { } const parentChannel = yield* channelRepo .findById(channel.parentChannelId as ChannelId) - .pipe(Effect.orDie) if (Option.isNone(parentChannel)) { return false diff --git a/packages/domain/src/error-utils.ts b/packages/domain/src/error-utils.ts index f0381ee65..eeee25c30 100644 --- a/packages/domain/src/error-utils.ts +++ b/packages/domain/src/error-utils.ts @@ -1,17 +1,26 @@ -import { Effect } from "effect" +import { Effect, Predicate } from "effect" import * as CurrentUser from "./current-user" -import { UnauthorizedError } from "./errors" +import { InternalServerError, UnauthorizedError } from "./errors" import { PermissionError } from "./scopes/permission-error" +/** Infrastructure failures a policy check can hit; they say nothing about permission. */ +type InfrastructureError = { readonly _tag: "DatabaseError" | "SchemaError" } + +const isInfrastructureError = (u: unknown): u is InfrastructureError => + Predicate.isTagged(u, "DatabaseError") || Predicate.isTagged(u, "SchemaError") + /** - * Transform any error into an UnauthorizedError with context about the action. - * Preserves existing UnauthorizedError instances. - * Converts PermissionError into UnauthorizedError (for policy boundaries). - * Uses the current user context to provide detailed error information. + * Transform a policy's failures into an UnauthorizedError with context about the action. + * Preserves existing UnauthorizedError instances and converts PermissionError into + * UnauthorizedError (for policy boundaries). + * + * Database and schema failures are not permission answers: they become an + * `InternalServerError` (cause kept server-side) instead of a 401, so a database blip never + * looks like "you may not do this" or a signed-out session. * * @param entity - The type of entity being accessed (e.g., "channel", "message") * @param action - The action being performed (e.g., "update", "delete") - * @returns A function that transforms effect errors into UnauthorizedError + * @returns A function that transforms effect errors into UnauthorizedError | InternalServerError * * @example * ```typescript @@ -21,27 +30,28 @@ import { PermissionError } from "./scopes/permission-error" export const refailUnauthorized = (entity: string, action: string) => { return <A, E, R>( effect: Effect.Effect<A, E, R>, - ): Effect.Effect<A, UnauthorizedError, CurrentUser.Context | R> => - Effect.catchIf( - effect, - (e) => !UnauthorizedError.is(e), - (e) => - CurrentUser.Context.use((actor) => { - // Convert PermissionError to UnauthorizedError with scope info - if (PermissionError.is(e)) { - return Effect.fail( + ): Effect.Effect<A, UnauthorizedError | InternalServerError, CurrentUser.Context | R> => + effect.pipe( + Effect.catchIf(isInfrastructureError, (cause) => + Effect.fail( + new InternalServerError({ + message: `Could not check permission to ${action} ${entity}`, + cause, + }), + ), + ), + Effect.catchIf( + (e) => !UnauthorizedError.is(e) && !InternalServerError.is(e), + (e) => + CurrentUser.Context.use((actor) => + Effect.fail( new UnauthorizedError({ - message: (e as PermissionError).message, + // Convert PermissionError to UnauthorizedError with scope info + message: PermissionError.is(e) ? e.message : `You can't ${action} this ${entity}`, detail: `You are not authorized to perform ${action} on ${entity} for ${actor.id}`, }), - ) - } - return Effect.fail( - new UnauthorizedError({ - message: `You can't ${action} this ${entity}`, - detail: `You are not authorized to perform ${action} on ${entity} for ${actor.id}`, - }), - ) - }), - ) as Effect.Effect<A, UnauthorizedError, CurrentUser.Context | R> + ), + ), + ), + ) as Effect.Effect<A, UnauthorizedError | InternalServerError, CurrentUser.Context | R> } diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index 228ed058f..cd46852d9 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -1,4 +1,4 @@ -import { Effect, Predicate, Schema, SchemaIssue } from "effect" +import { Effect, Predicate, Schema } from "effect" import { ChannelId, MessageId } from "@hazel/schema" export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>("UnauthorizedError")( @@ -54,26 +54,59 @@ export class OAuthRedemptionPendingError extends Schema.TaggedError<OAuthRedempt } } +/** + * Keeps the underlying failure on an error for server-side logs and traces without making it + * part of the error's schema: `cause` is the native, non-enumerable `Error.cause`, so it is never + * encoded into RPC or HTTP responses. + */ +const attachCause = (error: Error, cause: unknown) => { + if (cause === undefined) return + Object.defineProperty(error, "cause", { value: cause, enumerable: false, writable: true, configurable: true }) +} + +/** + * An unexpected server failure. Clients see only the stable `message` (and `detail`, which must + * be client-safe: never `String(error)` or driver text). Pass the underlying failure as `cause`: + * it stays on the server, where the error reporter logs it with the full `Cause`. + */ export class InternalServerError extends Schema.TaggedError<InternalServerError>("InternalServerError")( "InternalServerError", { message: Schema.String, detail: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Any), }, { httpApiStatus: 500 }, -) {} +) { + static is(u: unknown): u is InternalServerError { + return Predicate.isTagged(u, "InternalServerError") + } + + constructor( + props: { readonly message: string; readonly detail?: string | undefined; readonly cause?: unknown }, + options?: Schema.MakeOptions, + ) { + const { cause, ...fields } = props + super(fields, options) + attachCause(this, cause) + } +} +/** A workflow could not be started. `cause` stays server-side, as on `InternalServerError`. */ export class WorkflowInitializationError extends Schema.TaggedError<WorkflowInitializationError>( "WorkflowInitializationError", )( "WorkflowInitializationError", { message: Schema.String, - cause: Schema.optional(Schema.Any), }, { httpApiStatus: 500 }, -) {} +) { + constructor(props: { readonly message: string; readonly cause?: unknown }, options?: Schema.MakeOptions) { + const { cause, ...fields } = props + super(fields, options) + attachCause(this, cause) + } +} export class DmChannelAlreadyExistsError extends Schema.TaggedError<DmChannelAlreadyExistsError>( "DmChannelAlreadyExistsError", @@ -120,38 +153,54 @@ export class WorkflowServiceUnavailableError extends Schema.TaggedError<Workflow "WorkflowServiceUnavailableError", { message: Schema.String, - cause: Schema.optional(Schema.NullOr(Schema.String)), }, { httpApiStatus: 503 }, -) {} +) { + static is(u: unknown): u is WorkflowServiceUnavailableError { + return Predicate.isTagged(u, "WorkflowServiceUnavailableError") + } -export function withRemapDbErrors<R, E extends { _tag: string }, A>( + constructor(props: { readonly message: string; readonly cause?: unknown }, options?: Schema.MakeOptions) { + const { cause, ...fields } = props + super(fields, options) + attachCause(this, cause) + } +} + +/** + * Turns the repository layer's `DatabaseError` / `SchemaError` into a client-safe + * `InternalServerError`, keeping the original failure as its server-side `cause`. + */ +export function withRemapDbErrors( entityType: string, action: "update" | "create" | "delete" | "select", entityId?: unknown | { value: unknown; key: string }[], ) { - return ( - effect: Effect.Effect<R, E, A>, - ): Effect.Effect<R, Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> | InternalServerError, A> => { - const toInternalError = (err: unknown, detailPrefix: string) => + return <A, E, R>( + effect: Effect.Effect<A, E, R>, + ): Effect.Effect<A, Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> | InternalServerError, R> => { + const toInternalError = (cause: unknown, detailPrefix: string) => Effect.fail( new InternalServerError({ message: `Error ${action}ing ${entityType}`, detail: constructDetailMessage(detailPrefix, entityType, entityId), - cause: String(err), + cause, }), ) return effect.pipe( Effect.catchIf( - (e): e is Extract<E, { _tag: "DatabaseError" }> => Predicate.isTagged(e, "DatabaseError"), - (err) => toInternalError(err, "There was a database error when"), - ), - Effect.catchIf( - (e): e is Extract<E, { _tag: "SchemaError" }> => Predicate.isTagged(e, "SchemaError"), - (err) => toInternalError(err, "There was an error in parsing when"), + (e): e is Extract<E, { _tag: "DatabaseError" | "SchemaError" }> => + Predicate.isTagged(e, "DatabaseError") || Predicate.isTagged(e, "SchemaError"), + (err) => + toInternalError( + err, + Predicate.isTagged(err, "DatabaseError") + ? "There was a database error when" + : "There was an error in parsing when", + ), ), - ) as Effect.Effect<R, Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> | InternalServerError, A> + ) as Effect.Effect<A, Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> | InternalServerError, R> } } From 5346e2af12637ed117599d3f7292f6e658123476 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:55:27 +0200 Subject: [PATCH 07/52] refactor(web): render messages with the editor's parser and decorations, drop Slate The message viewer walked the Slate deserializer's output and Slate's decorators while the composer used the ProseMirror ports in editor/. The viewer now renders editor/markdown.ts blocks with editor/decorations.ts leaves (emoji ranges and the viewer's hidden markers move there) and a new editor/code-highlight.ts. detect-language moves to editor/. Prism's lazy language loader was never called, so only five languages ever highlighted. Every language it listed is now registered up front (plus markup-templating, which php needs), keeping the view a pure function. Deletes core/components/chat/slate-editor (and its duplicate serializer suite) and the slate dependency; drops unused @tanstack/react-db and @tanstack/store from the collection lib. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/package.json | 1 - .../src/chat/markdown/leaves.test.ts | 48 + apps/web-foldkit/src/chat/markdown/leaves.ts | 77 +- .../src/chat/markdown/markdown-view.ts | 129 +-- apps/web-foldkit/src/chat/message/content.ts | 13 +- apps/web-foldkit/src/chat/prism-modules.d.ts | 20 - .../chat/slate-editor/slate-code-decorator.ts | 151 --- .../slate-editor/slate-emoji-decorator.ts | 120 -- .../slate-markdown-decorations.ts | 224 ---- .../slate-markdown-serializer.test.ts | 1014 ----------------- .../slate-editor/slate-markdown-serializer.ts | 608 ---------- .../slate-message-viewer-model.ts | 42 - .../components/chat/slate-editor/types.ts | 136 --- apps/web-foldkit/src/editor/behavior.ts | 2 +- apps/web-foldkit/src/editor/code-highlight.ts | 61 + apps/web-foldkit/src/editor/decorations.ts | 86 +- .../detect-language.ts | 0 bun.lock | 11 +- .../package.json | 2 - 19 files changed, 285 insertions(+), 2460 deletions(-) create mode 100644 apps/web-foldkit/src/chat/markdown/leaves.test.ts delete mode 100644 apps/web-foldkit/src/chat/prism-modules.d.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-code-decorator.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-emoji-decorator.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-decorations.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.test.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/slate-message-viewer-model.ts delete mode 100644 apps/web-foldkit/src/core/components/chat/slate-editor/types.ts create mode 100644 apps/web-foldkit/src/editor/code-highlight.ts rename apps/web-foldkit/src/{core/components/chat/slate-editor => editor}/detect-language.ts (100%) diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index b634575e9..dfea95c41 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -43,7 +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" 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..44062634e --- /dev/null +++ b/apps/web-foldkit/src/chat/markdown/leaves.test.ts @@ -0,0 +1,48 @@ +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..3f8e61b85 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<CustomDescendant> => - "children" in element ? (element.children as CustomDescendant[]) : [] +/** Markdown and emoji leaves of one text run (`editor/decorations.ts`). */ +export const textLeaves = (text: string): ReadonlyArray<ViewerLeaf> => + 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<ViewerLeaf> => + codeLeaves(code, language).map((leaf) => ({ text: leaf.text, tokenClass: codeLeafClassName(leaf) })) export interface LeafContext { readonly isLast: boolean @@ -41,41 +37,28 @@ const leafString = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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..037d43bbf 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<number> +const childrenOf = (node: ProseMirrorNode): ReadonlyArray<ProseMirrorNode> => { + const children: Array<ProseMirrorNode> = [] + node.forEach((child) => children.push(child)) + return children +} -const textView = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, element: CustomElement, refs: MarkdownRefs): Html => { - const userId = "userId" in element ? String(element.userId) : "" - const displayName = "displayName" in element ? String(element.displayName) : "" +const mentionView = <M>(h: HtmlBuilder<M>, 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( @@ -139,8 +133,8 @@ const mentionView = <M>(h: HtmlBuilder<M>, element: CustomElement, refs: Markdow } /** `CustomEmojiElement`: the org's image, or `:name:` when the org has no such emoji. */ -const customEmojiView = <M>(h: HtmlBuilder<M>, element: CustomElement, refs: MarkdownRefs): Html => { - const name = "name" in element ? String(element.name) : "" +const customEmojiView = <M>(h: HtmlBuilder<M>, 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 +161,25 @@ const customEmojiView = <M>(h: HtmlBuilder<M>, element: CustomElement, refs: Mar ) } -const elementView = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 +190,16 @@ const elementView = <M>(h: HtmlBuilder<M>, 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 +212,9 @@ const elementView = <M>(h: HtmlBuilder<M>, element: CustomElement, path: Path, r } /** `TableElement`: the first row goes in a `thead` when it has header cells. */ -const tableView = <M>(h: HtmlBuilder<M>, element: CustomElement, children: ReadonlyArray<Html>): 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 = <M>(h: HtmlBuilder<M>, element: ProseMirrorNode, children: ReadonlyArray<Html>): 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 +230,7 @@ const tableView = <M>(h: HtmlBuilder<M>, element: CustomElement, children: Reado /** `SlateMessageViewer`. */ export const markdownView = <M>(h: HtmlBuilder<M>, content: string, refs: MarkdownRefs = NO_REFS): Html => { - const value: ReadonlyArray<CustomDescendant> = deserializeFromMarkdown(content) + const blocks = deserializeFromMarkdown(content) return h.div( [h.Class(cx("w-full", undefined))], [ @@ -256,11 +253,7 @@ export const markdownView = <M>(h: HtmlBuilder<M>, 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<string> = [] const extractUrlsFromParagraphs = (content: string): ReadonlyArray<string> => { 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/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/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<string>(["bash", "javascript", "json", "python", "typescript", "js", "ts"]) - -// Map of language aliases and their import paths -const languageImports: Record<string, () => Promise<unknown>> = { - 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<boolean> { - 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<string | Prism.Token>): 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<string, unknown>, 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<MarkdownRange> - 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<CustomText | MentionElement | CustomEmojiElement> { - const nodes: Array<CustomText | MentionElement | CustomEmojiElement> = [] - // 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<Record<string, unknown> & { 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<ViewerLeaf> { - 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<ViewerLeaf> { - 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/editor/behavior.ts b/apps/web-foldkit/src/editor/behavior.ts index 96a58a14a..45d1ad28e 100644 --- a/apps/web-foldkit/src/editor/behavior.ts +++ b/apps/web-foldkit/src/editor/behavior.ts @@ -1,4 +1,4 @@ -import { detectLanguage } from "~/components/chat/slate-editor/detect-language" +import { detectLanguage } from "./detect-language" import { joinBackward, joinForward } from "prosemirror-commands" import { redo, undo } from "prosemirror-history" import type { Node as ProseMirrorNode } from "prosemirror-model" diff --git a/apps/web-foldkit/src/editor/code-highlight.ts b/apps/web-foldkit/src/editor/code-highlight.ts new file mode 100644 index 000000000..0b765ecab --- /dev/null +++ b/apps/web-foldkit/src/editor/code-highlight.ts @@ -0,0 +1,61 @@ +import Prism from "prismjs" +import "prismjs/components/prism-bash" +import "prismjs/components/prism-c" +import "prismjs/components/prism-cpp" +import "prismjs/components/prism-csharp" +import "prismjs/components/prism-css" +import "prismjs/components/prism-diff" +import "prismjs/components/prism-go" +import "prismjs/components/prism-graphql" +import "prismjs/components/prism-java" +import "prismjs/components/prism-javascript" +import "prismjs/components/prism-json" +import "prismjs/components/prism-jsx" +import "prismjs/components/prism-kotlin" +import "prismjs/components/prism-markdown" +import "prismjs/components/prism-markup-templating" +import "prismjs/components/prism-php" +import "prismjs/components/prism-python" +import "prismjs/components/prism-ruby" +import "prismjs/components/prism-rust" +import "prismjs/components/prism-scss" +import "prismjs/components/prism-sql" +import "prismjs/components/prism-swift" +import "prismjs/components/prism-typescript" +import "prismjs/components/prism-tsx" +import "prismjs/components/prism-yaml" + +/** + * Prism highlighting for message code blocks, ported from the legacy `decorateCodeBlock`. Every + * language the legacy decorator could lazy-load is registered up front, so a view stays a pure + * function of the code (no load-then-rerender). + */ + +/** Aliases the legacy loader accepted that Prism doesn't register itself. */ +const ALIASES: Readonly<Record<string, string>> = { yml: "yaml", md: "markdown", rb: "ruby", rs: "rust" } + +/** A run of code and the Prism token types it is nested in (outermost first); none for plain text. */ +export interface CodeLeaf { + readonly text: string + readonly tokenTypes: ReadonlyArray<string> | null +} + +const flatten = (token: string | Prism.Token, types: ReadonlyArray<string>): ReadonlyArray<CodeLeaf> => { + if (typeof token === "string") return token.length > 0 ? [{ text: token, tokenTypes: types }] : [] + const nested = [...types, token.type] + return Array.isArray(token.content) + ? token.content.flatMap((child) => flatten(child, nested)) + : flatten(token.content, nested) +} + +/** The code split into Prism tokens, or one plain leaf when the language has no grammar. */ +export const codeLeaves = (code: string, language: string | null): ReadonlyArray<CodeLeaf> => { + const name = (language ?? "plaintext").toLowerCase() + const grammar = Prism.languages[ALIASES[name] ?? name] + if (!grammar || code === "") return [{ text: code, tokenTypes: null }] + return Prism.tokenize(code, grammar).flatMap((token) => flatten(token, [])) +} + +/** The legacy leaf class: `token` plus each token type once, in nesting order. */ +export const codeLeafClassName = (leaf: CodeLeaf): string => + leaf.tokenTypes === null ? "" : ["token", ...new Set(leaf.tokenTypes)].join(" ") diff --git a/apps/web-foldkit/src/editor/decorations.ts b/apps/web-foldkit/src/editor/decorations.ts index 93c8e7919..5f85d971c 100644 --- a/apps/web-foldkit/src/editor/decorations.ts +++ b/apps/web-foldkit/src/editor/decorations.ts @@ -1,9 +1,10 @@ import { Match } from "effect" +import { getEmojiShortcode } from "~/lib/emoji-shortcode-map" /** - * Discord-style markdown highlighting for the composer, ported from the legacy - * `slate-markdown-decorators.tsx` (`decorateMarkdown` + `MarkdownLeaf` in composer mode) - * and Slate's `Text.decorations` leaf splitting. Framework-free: text in, leaves out. + * Discord-style markdown highlighting for the composer and the message viewer, ported from the + * legacy Slate decorators (`decorateMarkdown`, `decorateEmoji`, `MarkdownLeaf`) and Slate's + * `Text.decorations` leaf splitting. Framework-free: text in, leaves out. * * The split and merge rules are Slate's, so leaf boundaries and classes match the legacy * DOM exactly, quirks included: `**bold**` also matches the italic pattern, which is @@ -26,11 +27,14 @@ export const URL_PATTERN = /(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/g export type MarkdownDecorationType = (typeof MARKDOWN_PATTERNS)[number]["type"] | "link" | "url" -interface LeafProps { - type?: MarkdownDecorationType +export interface LeafProps { + type?: MarkdownDecorationType | "emoji" isMarker?: boolean url?: string linkText?: string + /** A Unicode emoji with a known shortcode (viewer only). */ + emoji?: string + shortcode?: string } interface MarkdownRange extends LeafProps { @@ -93,8 +97,69 @@ export interface Leaf extends LeafProps { readonly end: number } +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +/** Codepoint ranges that make a grapheme an emoji; letters and digits never match. */ +const EMOJI_CODEPOINT_RANGES: ReadonlyArray<readonly [number, number]> = [ + [0x1f300, 0x1faff], // Pictographs, emoticons, transport, symbols, chess, extended-A + [0x1f1e6, 0x1f1ff], // Regional indicators (flags) + [0x2600, 0x27bf], // Misc symbols, dingbats + [0x231a, 0x231b], + [0x2328, 0x2328], + [0x23cf, 0x23cf], + [0x23e9, 0x23f3], + [0x23f8, 0x23fa], + [0x200d, 0x200d], // ZWJ + [0xfe00, 0xfe0f], // Variation selectors + [0x2b05, 0x2b07], + [0x2b1b, 0x2b1c], + [0x2b50, 0x2b50], + [0x2b55, 0x2b55], + [0x2934, 0x2935], + [0x25aa, 0x25ab], + [0x25b6, 0x25b6], + [0x25c0, 0x25c0], + [0x25fb, 0x25fe], + [0x2139, 0x2139], + [0x2194, 0x2199], + [0x21a9, 0x21aa], + [0x00a9, 0x00a9], + [0x00ae, 0x00ae], + [0x2122, 0x2122], + [0x24c2, 0x24c2], + [0x3030, 0x3030], + [0x303d, 0x303d], + [0x3297, 0x3297], + [0x3299, 0x3299], +] + +const isEmojiGrapheme = (grapheme: string): boolean => { + for (const char of grapheme) { + const code = char.codePointAt(0) ?? 0 + if (EMOJI_CODEPOINT_RANGES.some(([low, high]) => code >= low && code <= high)) return true + } + return false +} + +interface EmojiRange extends LeafProps { + readonly start: number + readonly end: number +} + +/** `decorateEmoji`: Unicode emoji with a known shortcode, by grapheme (ZWJ sequences, flags, skin tones). */ +export function decorateEmoji(text: string): ReadonlyArray<EmojiRange> { + const ranges: Array<EmojiRange> = [] + for (const { segment, index } of segmenter.segment(text)) { + if (!isEmojiGrapheme(segment)) continue + const shortcode = getEmojiShortcode(segment) + if (!shortcode) continue + ranges.push({ start: index, end: index + segment.length, type: "emoji", emoji: segment, shortcode }) + } + return ranges +} + /** Slate's `Text.decorations`: split the text at range edges and `Object.assign` each range in order. */ -export function splitLeaves(text: string, ranges: ReadonlyArray<MarkdownRange>): ReadonlyArray<Leaf> { +export function splitLeaves(text: string, ranges: ReadonlyArray<MarkdownRange | EmojiRange>): ReadonlyArray<Leaf> { type Mutable = { text: string; props: LeafProps } let leaves: Array<Mutable> = [{ text, props: {} }] for (const { start: decorationStart, end: decorationEnd, ...props } of ranges) { @@ -161,9 +226,18 @@ export function leafClassName(leaf: LeafProps): string { Match.when("highlight", () => "bg-highlight rounded px-0.5"), Match.when("spoiler", () => "bg-muted blur-sm hover:blur-none transition-all"), Match.whenOr("link", "url", () => LINK_CLASS), + Match.when("emoji", () => ""), Match.exhaustive, ) } +/** `MarkdownLeaf` in viewer mode: markers are hidden instead of dimmed. */ +export const viewerLeafClassName = (leaf: LeafProps): string => + leaf.type && leaf.isMarker ? "hidden" : leafClassName(leaf) + /** Leaves for one text run, ready to render. */ export const markdownLeaves = (text: string) => splitLeaves(text, decorateMarkdown(text)) + +/** The viewer's leaves for one text run: markdown, then emoji (the viewer's `decorate` order). */ +export const viewerLeaves = (text: string) => + splitLeaves(text, [...decorateMarkdown(text), ...decorateEmoji(text)]) diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/detect-language.ts b/apps/web-foldkit/src/editor/detect-language.ts similarity index 100% rename from apps/web-foldkit/src/core/components/chat/slate-editor/detect-language.ts rename to apps/web-foldkit/src/editor/detect-language.ts diff --git a/bun.lock b/bun.lock index a24bdeae8..789f24b93 100644 --- a/bun.lock +++ b/bun.lock @@ -276,7 +276,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", @@ -364,8 +363,6 @@ "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.11.3", "@tanstack/electric-db-collection": "0.5.4", - "@tanstack/react-db": "0.5.3", - "@tanstack/store": "^0.8.0", "effect": "catalog:effect", }, "devDependencies": { @@ -1593,8 +1590,6 @@ "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], - "@tanstack/react-db": ["@tanstack/react-db@0.5.3", "", { "dependencies": { "@tanstack/db": "0.11.3" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-UVR/gsm5Q4uGi6mrLXVsu7ZgNh9MokZNd9ZBAzg286P2TaBNZvnPUA6K09FydPlIRz+M1gC7f/5o1FHCwPLrFw=="], - "@tanstack/react-router": ["@tanstack/react-router@1.166.6", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-lfymPCfTkLQaNj/KIPElt+6B9REwPw2/Of3KtMwhNINs7h2xFQMSAOYk+ItCv8i93lBczlg89BRHtRS99qmzyA=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.6", "", { "dependencies": { "@tanstack/router-devtools-core": "1.166.6" }, "peerDependencies": { "@tanstack/react-router": "^1.166.6", "@tanstack/router-core": "^1.166.6", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-TheVyOgo8ljD8wHHLceFsnKrX7nhTIQv9WokSrPjNTP4H3synUMADxh8yZafVYdr6lS2CBvldd5s7JI8DcwBUg=="], @@ -1627,7 +1622,7 @@ "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.144.0", "", { "dependencies": { "@tanstack/router-core": "1.144.0" } }, "sha512-DuUx5CXfLNettyJlsHDQp66y5haeqzXJkUor7kp5p10SVv24p76dTYqBOpw+wQz//RfJlOciIZFVBcKezXXY0w=="], - "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="], @@ -3121,8 +3116,6 @@ "sitemap": ["sitemap@8.0.3", "", { "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/cli.js" } }, "sha512-9Ew1tR2WYw8RGE2XLy7GjkusvYXy8Rg6y8TYuBuQMfIEdGcWoJpY2Wr5DzsEiL/TKCw56+YKTCCUHglorEYK+A=="], - "slate": ["slate@0.123.0", "", {}, "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg=="], - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "smartquotes": ["smartquotes@2.3.2", "", {}, "sha512-0R6YJ5hLpDH4mZR7N5eZ12oCMLspvGOHL9A9SEm2e3b/CQmQidekW4SWSKEmor/3x6m3NCBBEqLzikcZC9VJNQ=="], @@ -3659,8 +3652,6 @@ "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "@tanstack/hotkeys/@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], - "@tanstack/react-start/@tanstack/react-router": ["@tanstack/react-router@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.144.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA=="], "@tanstack/react-start-client/@tanstack/react-router": ["@tanstack/react-router@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.144.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA=="], diff --git a/libs/effect-electric-db-collection/package.json b/libs/effect-electric-db-collection/package.json index b47ce8be3..8ddb5d7d2 100644 --- a/libs/effect-electric-db-collection/package.json +++ b/libs/effect-electric-db-collection/package.json @@ -16,8 +16,6 @@ "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.11.3", "@tanstack/electric-db-collection": "0.5.4", - "@tanstack/react-db": "0.5.3", - "@tanstack/store": "^0.8.0", "effect": "catalog:effect" }, "devDependencies": { From 44c9a66d0d90a2dbfffd86c446c5322ed709cb55 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:55:58 +0200 Subject: [PATCH 08/52] fix(backend): log server failures centrally and stop sending driver text to clients About 60 handlers put `String(err)` (SQL and driver messages) into the `detail` or `cause` of InternalServerError / Workflow*Error, which went to RPC and HTTP clients verbatim; `String()` on a tagged error also dropped its message from the logs. They now pass the failure itself as the non-serialized `cause`. A ServerFailureReporter, registered on the route graph, receives every failed RPC and HttpApi request (both servers report to CurrentErrorReporters) and logs 5xx failures and defects with their full Cause. UploadError gets the same server-side cause instead of interpolating the S3 error message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/backend/src/app.ts | 3 + apps/backend/src/lib/error-reporter.test.ts | 58 +++++++++++++++++++ apps/backend/src/lib/error-reporter.ts | 40 +++++++++++++ .../src/routes/api-v1/integrations.http.ts | 10 ++-- .../src/routes/api-v1/messages.http.ts | 10 ++-- apps/backend/src/routes/bot-commands.http.ts | 10 ++-- apps/backend/src/routes/chat-sync.http.ts | 2 +- .../src/routes/incoming-webhooks.http.ts | 16 ++--- .../src/routes/integration-commands.http.ts | 2 +- .../src/routes/integration-resources.http.ts | 12 ++-- apps/backend/src/routes/integrations.http.ts | 24 ++++---- apps/backend/src/routes/uploads.http.ts | 15 +++-- apps/backend/src/rpc/handlers/channels.ts | 6 +- apps/backend/src/rpc/handlers/chat-sync.ts | 18 +++--- .../backend/src/rpc/handlers/organizations.ts | 6 +- packages/domain/src/errors.ts | 2 +- packages/domain/src/http/uploads.ts | 10 +++- 17 files changed, 179 insertions(+), 65 deletions(-) create mode 100644 apps/backend/src/lib/error-reporter.test.ts create mode 100644 apps/backend/src/lib/error-reporter.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 938561893..0943ae661 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -45,6 +45,7 @@ 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" @@ -126,6 +127,8 @@ export const AllRoutes = Layer.mergeAll( credentials: true, }), ), + // RpcServer and HttpApiBuilder report failed requests here; server failures get logged. + Layer.provide(ServerFailureReporterLive), ) /** 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..9ac4e1fa5 --- /dev/null +++ b/apps/backend/src/lib/error-reporter.test.ts @@ -0,0 +1,58 @@ +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<string>) => { + const captureLogger = Logger.make<unknown, void>(({ 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<string> = [] + const { handler, dispose } = serve(logged) + try { + const response = await handler( + new Request("http://localhost/boom"), + Context.empty() as Context.Context<any>, + ) + 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<string> = new Set([ + "InternalServerError", + "WorkflowInitializationError", + "WorkflowServiceUnavailableError", + "UploadError", + // Escaped the remapping to InternalServerError; still a server failure. + "DatabaseError", + "SchemaError", +]) + +const isServerFailure = (reason: Cause.Reason<unknown>): 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/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..126c39f70 100644 --- a/apps/backend/src/routes/chat-sync.http.ts +++ b/apps/backend/src/routes/chat-sync.http.ts @@ -37,7 +37,7 @@ const ensureOrgAccess = Effect.fn("chatSync.ensureOrgAccess")(function* (organiz const toInternalServerError = (message: string, error: unknown) => new InternalServerError({ message, - detail: String(error), + cause: error, }) const normalizeChannelLinkSettings = ( diff --git a/apps/backend/src/routes/incoming-webhooks.http.ts b/apps/backend/src/routes/incoming-webhooks.http.ts index 13e440bc8..74a6e3470 100644 --- a/apps/backend/src/routes/incoming-webhooks.http.ts +++ b/apps/backend/src/routes/incoming-webhooks.http.ts @@ -152,14 +152,14 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", - detail: String(error), + cause: error, }), ), }), @@ -255,14 +255,14 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", - detail: String(error), + cause: error, }), ), }), @@ -355,14 +355,14 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", - detail: String(error), + cause: error, }), ), }), @@ -448,14 +448,14 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), SchemaError: (error: unknown) => Effect.fail( new InternalServerError({ message: "Invalid request data", - detail: String(error), + cause: error, }), ), }), 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.ts b/apps/backend/src/routes/integrations.http.ts index 8ced255fa..e77b2ea98 100644 --- a/apps/backend/src/routes/integrations.http.ts +++ b/apps/backend/src/routes/integrations.http.ts @@ -262,7 +262,7 @@ const makeOAuthSessionCookie = ( catch: (error) => new InternalServerError({ message: "Failed to create OAuth session cookie", - detail: String(error), + cause: error, }), }) @@ -306,7 +306,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( (error) => new InternalServerError({ message: `Provider not available: ${error._tag}`, - detail: String(error), + cause: error, }), ), ) @@ -321,7 +321,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( (error) => new InternalServerError({ message: "Failed to fetch organization", - detail: String(error), + cause: error, }), ), ) @@ -404,7 +404,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) }), + new InternalServerError({ message: "Failed to serialize response", cause: e }), ), ), ) @@ -1210,7 +1210,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" handleGetOAuthUrl(params, query).pipe( Effect.catchTag("ConfigError", (err) => Effect.fail( - new InternalServerError({ message: "Missing configuration", detail: String(err) }), + new InternalServerError({ message: "Missing configuration", cause: err }), ), ), ), @@ -1221,13 +1221,13 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" Effect.fail( new InternalServerError({ message: "Database error during OAuth callback", - detail: String(error), + cause: error, }), ), ), Effect.catchTag("ConfigError", (err) => Effect.fail( - new InternalServerError({ message: "Missing configuration", detail: String(err) }), + new InternalServerError({ message: "Missing configuration", cause: err }), ), ), ), @@ -1239,14 +1239,14 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" Effect.fail( new InternalServerError({ message: "Database error during API key connection", - detail: String(error), + cause: error, }), ), SchemaError: (error: { readonly _tag: "SchemaError"; readonly message?: string }) => Effect.fail( new InternalServerError({ message: "Failed to parse API response", - detail: String(error), + cause: error, }), ), IntegrationEncryptionError: (error: { @@ -1256,7 +1256,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" Effect.fail( new InternalServerError({ message: "Failed to encrypt token", - detail: String(error), + cause: error, }), ), }), @@ -1268,7 +1268,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" Effect.fail( new InternalServerError({ message: "Failed to get connection status", - detail: String(error), + cause: error, }), ), ), @@ -1280,7 +1280,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" Effect.fail( new InternalServerError({ message: "Failed to disconnect integration", - detail: String(error), + cause: error, }), ), ), 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/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 05fa76a6f..01bedcf6b 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -436,7 +436,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Failed to query channel", - cause: String(err), + cause: err, }), ), ), @@ -506,7 +506,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.fail( new WorkflowServiceUnavailableError({ message: "Cannot connect to workflow service", - cause: String(err), + cause: err, }), ), ), @@ -514,7 +514,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..d97789178 100644 --- a/apps/backend/src/rpc/handlers/chat-sync.ts +++ b/apps/backend/src/rpc/handlers/chat-sync.ts @@ -119,7 +119,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Invalid sync connection data", - detail: String(error), + cause: error, }), ), ), @@ -127,7 +127,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while creating sync connection", - detail: String(error), + cause: error, }), ), ), @@ -143,7 +143,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while listing sync connections", - detail: String(error), + cause: error, }), ), ), @@ -179,7 +179,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while deleting sync connection", - detail: String(error), + cause: error, }), ), ), @@ -256,7 +256,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Invalid channel link data", - detail: String(error), + cause: error, }), ), ), @@ -264,7 +264,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while creating channel link", - detail: String(error), + cause: error, }), ), ), @@ -290,7 +290,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while listing channel links", - detail: String(error), + cause: error, }), ), ), @@ -333,7 +333,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while deleting channel link", - detail: String(error), + cause: error, }), ), ), @@ -393,7 +393,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Database error while updating channel link", - detail: String(error), + cause: error, }), ), ), diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index d10656709..fae5eae86 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -64,7 +64,7 @@ const handleOrganizationDbErrors = <R, E extends { _tag: string }, A>( new InternalServerError({ message: `Error ${action}ing ${entityType}`, detail: `There was a database error when ${action}ing the ${entityType}`, - cause: String(err), + cause: err, }), ) }, @@ -76,7 +76,7 @@ const handleOrganizationDbErrors = <R, E extends { _tag: string }, A>( new InternalServerError({ message: `Error ${action}ing ${entityType}`, detail: `There was an error in parsing when ${action}ing the ${entityType}`, - cause: String(err), + cause: err, }), ), ), @@ -292,7 +292,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Error fetching organization", - detail: String(err), + cause: err, }), ), }), diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index cd46852d9..a4e22d467 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -59,7 +59,7 @@ export class OAuthRedemptionPendingError extends Schema.TaggedError<OAuthRedempt * part of the error's schema: `cause` is the native, non-enumerable `Error.cause`, so it is never * encoded into RPC or HTTP responses. */ -const attachCause = (error: Error, cause: unknown) => { +export const attachCause = (error: Error, cause: unknown) => { if (cause === undefined) return Object.defineProperty(error, "cause", { value: cause, enumerable: false, writable: true, configurable: true }) } diff --git a/packages/domain/src/http/uploads.ts b/packages/domain/src/http/uploads.ts index eb7897c2c..793c20fe7 100644 --- a/packages/domain/src/http/uploads.ts +++ b/packages/domain/src/http/uploads.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { CurrentUser, InternalServerError, UnauthorizedError } from "../" +import { attachCause } from "../errors" import { AttachmentId, BotId, ChannelId, OrganizationId } from "@hazel/schema" import { RateLimitExceededError } from "../rate-limit-errors" import { RequiredScopes } from "../scopes/required-scopes" @@ -115,13 +116,20 @@ export class PresignUploadResponse extends Schema.Class<PresignUploadResponse>(" // ============ Error Schemas ============ +/** The upload could not be prepared. `cause` stays server-side, as on `InternalServerError`. */ export class UploadError extends Schema.TaggedError<UploadError>("UploadError")( "UploadError", { message: Schema.String, }, { httpApiStatus: 500 }, -) {} +) { + constructor(props: { readonly message: string; readonly cause?: unknown }, options?: Schema.MakeOptions) { + const { cause, ...fields } = props + super(fields, options) + attachCause(this, cause) + } +} export class BotNotFoundForUploadError extends Schema.TaggedError<BotNotFoundForUploadError>( "BotNotFoundForUploadError", From 3a7aec2b4c3a75928b48db9f5347d1c5fc82956b Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:56:26 +0200 Subject: [PATCH 09/52] refactor(web): drop React Aria DOM emulation for native Tailwind variants Removes data-rac and the tailwindcss-react-aria-components plugin. Hover, focus and focus-visible are now native CSS; state the browser cannot see uses Tailwind data variants (data-selected:, data-pressed:, data-disabled:, data-[placement=...]:, ...). Fixes missing keyboard focus rings and hover on switches, checkboxes and radios without interaction wiring (now group-hover/group-has-focus-visible on the label, wiring removed), and the dead hover on the stateless tab strip, which the chat tab bar now reuses. Tabs get a visible focus-visible ring; disabled and pending buttons no longer show hover. React Aria default class names are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/package.json | 1 - .../web-foldkit/src/chat/embeds/url-embeds.ts | 6 +- apps/web-foldkit/src/chat/image-viewer.ts | 2 +- .../src/chat/markdown/markdown-view.ts | 1 - apps/web-foldkit/src/chat/message/row.ts | 1 - apps/web-foldkit/src/chat/message/toolbar.ts | 1 - apps/web-foldkit/src/chat/message/tooltips.ts | 1 - apps/web-foldkit/src/chat/profile-popover.ts | 1 - apps/web-foldkit/src/composer/view.ts | 3 +- .../src/core/components/ui/button.styles.ts | 6 +- .../src/core/components/ui/calendar.styles.ts | 4 +- .../src/core/components/ui/checkbox.styles.ts | 20 +-- .../core/components/ui/choice-box.styles.ts | 8 +- .../core/components/ui/command-menu.styles.ts | 8 +- .../core/components/ui/date-field.styles.ts | 10 +- .../core/components/ui/date-picker.styles.ts | 2 +- .../src/core/components/ui/dialog.styles.ts | 2 +- .../src/core/components/ui/dropdown.styles.ts | 20 +-- .../src/core/components/ui/field.styles.ts | 6 +- .../src/core/components/ui/input.styles.ts | 6 +- .../src/core/components/ui/keyboard.styles.ts | 2 +- .../src/core/components/ui/link.styles.ts | 2 +- .../src/core/components/ui/menu.styles.ts | 6 +- .../src/core/components/ui/modal.styles.ts | 8 +- .../src/core/components/ui/popover.styles.ts | 8 +- .../src/core/components/ui/radio.styles.ts | 14 +- .../core/components/ui/search-field.styles.ts | 4 +- .../src/core/components/ui/select.styles.ts | 12 +- .../src/core/components/ui/sheet.styles.ts | 8 +- .../src/core/components/ui/slider.styles.ts | 12 +- .../src/core/components/ui/switch.styles.ts | 18 ++- .../src/core/components/ui/tabs.styles.ts | 6 +- .../src/core/components/ui/textarea.styles.ts | 6 +- .../src/core/components/ui/tooltip.styles.ts | 6 +- apps/web-foldkit/src/core/styles/styles.css | 2 - apps/web-foldkit/src/gif-picker/view.ts | 3 - .../src/overlay/modal/bot-avatar-upload.ts | 1 - .../src/page/chat/overlay-views.ts | 1 - apps/web-foldkit/src/page/chat/pinned.ts | 1 - apps/web-foldkit/src/page/chat/tab-bar.ts | 116 +++------------ apps/web-foldkit/src/page/home/view.ts | 4 +- .../src/page/my-settings/appearance/view.ts | 1 - .../src/page/my-settings/desktop/view.ts | 1 - .../page/my-settings/notifications/view.ts | 3 - .../page/my-settings/profile/avatar-view.ts | 2 - .../src/page/onboarding/steps/theme.ts | 3 - .../settings/chat-sync-connection/parts.ts | 1 - .../src/page/settings/chat-sync/view.ts | 1 - .../src/page/settings/custom-emojis/view.ts | 3 - .../web-foldkit/src/picker-popover/popover.ts | 3 - .../src/shell/channels-sidebar/items.ts | 1 - .../src/shell/channels-sidebar/tree.ts | 6 +- apps/web-foldkit/src/shell/mobile.ts | 2 +- apps/web-foldkit/src/ui/aria-radio.ts | 6 +- apps/web-foldkit/src/ui/aria/interaction.ts | 6 +- apps/web-foldkit/src/ui/button.ts | 13 +- apps/web-foldkit/src/ui/calendar-view.ts | 8 +- .../web-foldkit/src/ui/checkbox.scene.test.ts | 55 ++----- apps/web-foldkit/src/ui/checkbox.ts | 135 +----------------- apps/web-foldkit/src/ui/choice-box.ts | 3 - apps/web-foldkit/src/ui/combo-box-view.ts | 4 - apps/web-foldkit/src/ui/command-menu-view.ts | 7 - apps/web-foldkit/src/ui/date-field.ts | 5 - apps/web-foldkit/src/ui/date-picker-view.ts | 5 - apps/web-foldkit/src/ui/dialog.ts | 1 - apps/web-foldkit/src/ui/field.ts | 1 - apps/web-foldkit/src/ui/input.ts | 2 - apps/web-foldkit/src/ui/link.ts | 1 - apps/web-foldkit/src/ui/loader.ts | 2 - apps/web-foldkit/src/ui/menu-view.ts | 5 - apps/web-foldkit/src/ui/modal.ts | 2 - apps/web-foldkit/src/ui/popover.ts | 3 +- apps/web-foldkit/src/ui/progress-bar.ts | 1 - apps/web-foldkit/src/ui/radio.scene.test.ts | 5 +- apps/web-foldkit/src/ui/radio.ts | 31 +--- apps/web-foldkit/src/ui/search-field.ts | 1 - apps/web-foldkit/src/ui/select-view.ts | 6 - apps/web-foldkit/src/ui/sheet.ts | 2 - apps/web-foldkit/src/ui/sidebar.ts | 2 +- apps/web-foldkit/src/ui/slider.ts | 4 - apps/web-foldkit/src/ui/switch.scene.test.ts | 22 ++- apps/web-foldkit/src/ui/switch.ts | 44 +----- apps/web-foldkit/src/ui/tab-strip.ts | 69 +++------ apps/web-foldkit/src/ui/tabs.ts | 5 - apps/web-foldkit/src/ui/text-field.ts | 1 - apps/web-foldkit/src/ui/textarea.ts | 1 - apps/web-foldkit/src/ui/toolbar.ts | 1 - apps/web-foldkit/src/ui/tooltip.ts | 3 +- bun.lock | 3 - 89 files changed, 188 insertions(+), 653 deletions(-) diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index b634575e9..54135cb54 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -55,7 +55,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/src/chat/embeds/url-embeds.ts b/apps/web-foldkit/src/chat/embeds/url-embeds.ts index 553881246..4c7968056 100644 --- a/apps/web-foldkit/src/chat/embeds/url-embeds.ts +++ b/apps/web-foldkit/src/chat/embeds/url-embeds.ts @@ -24,7 +24,7 @@ export const linkPreviewView = <M>(h: HtmlBuilder<M>, 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", ), ], [ @@ -63,7 +63,7 @@ export const youtubeView = <M>(h: HtmlBuilder<M>, 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", ), ], [ @@ -352,7 +352,7 @@ export const tweetView = <M>( 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)], diff --git a/apps/web-foldkit/src/chat/image-viewer.ts b/apps/web-foldkit/src/chat/image-viewer.ts index db03b0426..747dfd676 100644 --- a/apps/web-foldkit/src/chat/image-viewer.ts +++ b/apps/web-foldkit/src/chat/image-viewer.ts @@ -142,7 +142,7 @@ const ImageViewerPortal = Mount.defineStream("ImageViewerPortal", { const toolbarButton = <M>(h: HtmlBuilder<M>, onPress: M | undefined, 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, { diff --git a/apps/web-foldkit/src/chat/markdown/markdown-view.ts b/apps/web-foldkit/src/chat/markdown/markdown-view.ts index dea421e7f..d051f5aff 100644 --- a/apps/web-foldkit/src/chat/markdown/markdown-view.ts +++ b/apps/web-foldkit/src/chat/markdown/markdown-view.ts @@ -128,7 +128,6 @@ const mentionView = <M>(h: HtmlBuilder<M>, 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"), diff --git a/apps/web-foldkit/src/chat/message/row.ts b/apps/web-foldkit/src/chat/message/row.ts index a46e4d820..aaa81397a 100644 --- a/apps/web-foldkit/src/chat/message/row.ts +++ b/apps/web-foldkit/src/chat/message/row.ts @@ -68,7 +68,6 @@ export const avatarButton = <M>( [ ...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"), diff --git a/apps/web-foldkit/src/chat/message/toolbar.ts b/apps/web-foldkit/src/chat/message/toolbar.ts index 80252b099..7f3af3c42 100644 --- a/apps/web-foldkit/src/chat/message/toolbar.ts +++ b/apps/web-foldkit/src/chat/message/toolbar.ts @@ -250,7 +250,6 @@ const action = <M>( ...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")] diff --git a/apps/web-foldkit/src/chat/message/tooltips.ts b/apps/web-foldkit/src/chat/message/tooltips.ts index 1369caa2e..2d94d5b63 100644 --- a/apps/web-foldkit/src/chat/message/tooltips.ts +++ b/apps/web-foldkit/src/chat/message/tooltips.ts @@ -16,7 +16,6 @@ export interface TooltipContext<M> { } const PRESSABLE = <M>(h: HtmlBuilder<M>) => [ - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("tabindex", "0"), h.Attribute("type", "button"), 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 = <M>(h: HtmlBuilder<M>, inputs: ProfileInputs): ReadonlyArray<H h.Attribute("aria-expanded", "false"), h.Attribute("aria-haspopup", "true"), h.Class(menuTriggerClassName()), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("data-slot", "menu-trigger"), h.Attribute("tabindex", "0"), diff --git a/apps/web-foldkit/src/composer/view.ts b/apps/web-foldkit/src/composer/view.ts index 77d6ddf0c..3dfa3489b 100644 --- a/apps/web-foldkit/src/composer/view.ts +++ b/apps/web-foldkit/src/composer/view.ts @@ -43,7 +43,6 @@ const pickerButton = [ ...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"), @@ -93,7 +92,7 @@ const actions = <M>(h: HtmlBuilder<M>, extras: BoxExtras<M>): Array<Html> => [ /** The whole composer box for a composer Model, in the parent's Message type. */ export const composerBoxView = <M>(h: HtmlBuilder<M>, model: Model, extras: BoxExtras<M>): Html => h.div( - [h.Class("relative"), h.Attribute("data-rac", ""), ...(extras.dropZoneAttributes ?? [])], + [h.Class("relative"), ...(extras.dropZoneAttributes ?? [])], [ h.div( [h.Attribute("style", VISUALLY_HIDDEN)], 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..067ae5ce2 100644 --- a/apps/web-foldkit/src/core/components/ui/button.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/button.styles.ts @@ -4,12 +4,12 @@ import { tv } from "tailwind-variants" 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 d841a6da5..6d7bda7c0 100644 --- a/apps/web-foldkit/src/core/components/ui/calendar.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/calendar.styles.ts @@ -13,9 +13,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, ) 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..9a2376206 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 @@ -55,10 +55,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 +79,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 +90,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/command-menu.styles.ts b/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts index 39156deb1..365b7005a 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 @@ -17,8 +17,8 @@ export const commandMenuOverlayClassName = (isBlurred: boolean) => twJoin( "fixed inset-0 z-50 h-(--visual-viewport-height,100vh) 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", + "data-entering:fade-in data-entering:animate-in data-entering:duration-300 data-entering:ease-out", + "data-exiting:fade-out data-exiting:animate-out data-exiting:ease-in", isBlurred && "backdrop-blur-sm backdrop-filter", ) @@ -29,8 +29,8 @@ export const commandMenuModalBase = (size: CommandMenuSize) => "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", "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", + "data-entering:slide-in-from-bottom sm:data-entering:zoom-in-95 sm:data-entering:slide-in-from-bottom-0 data-entering:animate-in data-entering:duration-300 data-entering:ease-out", + "data-exiting:slide-out-to-bottom sm:data-exiting:zoom-out-95 sm:data-exiting:slide-out-to-bottom-0 data-exiting:animate-out data-exiting:ease-in", ] as const export const commandMenuDialogClassName = "flex max-h-[inherit] flex-col overflow-hidden 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..5a34ad379 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 @@ -4,9 +4,9 @@ export const dateInputStyles = [ "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 +15,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..26b2da27a 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 @@ -12,7 +12,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 5c44f905d..154e909dc 100644 --- a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts @@ -7,7 +7,7 @@ export const dialogHeaderBase = "relative space-y-1 p-(--gutter) pb-[calc(var(-- 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", 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..6e453c38d 100644 --- a/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts @@ -25,21 +25,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 +80,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/field.styles.ts b/apps/web-foldkit/src/core/components/ui/field.styles.ts index ce5fb205b..aee30d591 100644 --- a/apps/web-foldkit/src/core/components/ui/field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/field.styles.ts @@ -2,17 +2,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]", ], }) 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..888c3fdf9 100644 --- a/apps/web-foldkit/src/core/components/ui/input.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/input.styles.ts @@ -4,10 +4,10 @@ export const inputStyles = [ "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..27c2721bb 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,3 @@ /** 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..1b4c70d16 100644 --- a/apps/web-foldkit/src/core/components/ui/link.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/link.styles.ts @@ -4,6 +4,6 @@ import type { ClassNameValue } from "tailwind-merge" 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/menu.styles.ts b/apps/web-foldkit/src/core/components/ui/menu.styles.ts index d70797ae8..bafdf6271 100644 --- a/apps/web-foldkit/src/core/components/ui/menu.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/menu.styles.ts @@ -15,9 +15,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..4c66a8406 100644 --- a/apps/web-foldkit/src/core/components/ui/modal.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/modal.styles.ts @@ -22,8 +22,8 @@ export const modalOverlayClassName = (size: ModalSize, isBlurred: boolean) => "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]", 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", + "data-entering:fade-in data-entering:animate-in data-entering:duration-300 data-entering:ease-out", + "data-exiting:fade-out data-exiting:animate-out data-exiting:ease-in", isBlurred && "backdrop-blur-[1px]", ) @@ -40,6 +40,6 @@ export const modalContentBase = (size: ModalSize) => 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", + "data-entering:slide-in-from-bottom sm:data-entering:zoom-in-95 sm:data-entering:slide-in-from-bottom-0 data-entering:animate-in data-entering:duration-300 data-entering:ease-out", + "data-exiting:slide-out-to-bottom sm:data-exiting:zoom-out-95 sm:data-exiting:slide-out-to-bottom-0 data-exiting:animate-out data-exiting:ease-in", ] 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..5c5529af2 100644 --- a/apps/web-foldkit/src/core/components/ui/popover.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/popover.styles.ts @@ -1,14 +1,14 @@ /** Shared by the React and Foldkit apps: keep this file framework-free. */ 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", + "data-entering:fade-in data-exiting:fade-out data-entering:animate-in data-exiting:animate-out", + "data-[placement=left]:data-entering:slide-in-from-right-1 data-[placement=right]:data-entering:slide-in-from-left-1 data-[placement=top]:data-entering:slide-in-from-bottom-1 data-[placement=bottom]:data-entering:slide-in-from-top-1", + "data-[placement=left]:data-exiting:slide-out-to-right-1 data-[placement=right]:data-exiting:slide-out-to-left-1 data-[placement=top]:data-exiting:slide-out-to-bottom-1 data-[placement=bottom]:data-exiting:slide-out-to-top-1", "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]" + "block fill-overlay stroke-border group-data-[placement=bottom]:rotate-180 group-data-[placement=left]:-rotate-90 group-data-[placement=right]:rotate-90 forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]" export const popoverInnerClassName = "max-h-[inherit] overflow-y-auto" 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..a030dbf3d 100644 --- a/apps/web-foldkit/src/core/components/ui/radio.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/radio.styles.ts @@ -6,7 +6,7 @@ export const radioGroupStyles = [ "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 +19,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..024dfc096 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 @@ -3,10 +3,10 @@ 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/select.styles.ts b/apps/web-foldkit/src/core/components/ui/select.styles.ts index c85657737..82a257d5c 100644 --- a/apps/web-foldkit/src/core/components/ui/select.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/select.styles.ts @@ -13,13 +13,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/sheet.styles.ts b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts index 88131818d..ee2f99441 100644 --- a/apps/web-foldkit/src/core/components/ui/sheet.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts @@ -31,10 +31,10 @@ export const sheetContentStyles = tv({ 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: "data-entering:slide-in-from-top data-exiting:slide-out-to-top inset-x-0 top-0 rounded-b-2xl border-b", + bottom: "data-entering:slide-in-from-bottom data-exiting:slide-out-to-bottom inset-x-0 bottom-0 rounded-t-2xl border-t", + left: "data-entering:slide-in-from-left data-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: "data-entering:slide-in-from-right data-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", }, isFloat: { false: "border-fg/20 dark:border-border", 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..c5013df45 100644 --- a/apps/web-foldkit/src/core/components/ui/slider.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/slider.styles.ts @@ -3,9 +3,9 @@ export const sliderGroupStyles = "flex items-center gap-x-3 *:data-[slot=icon]:s /** Slider root: `cx(...sliderStyles, className)`. */ 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" @@ -17,12 +17,12 @@ export const sliderThumbStyles = 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" + "group-orientation-horizontal/top-0 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" /** SliderFill's inline style for one thumb (a width) or a range (offset plus width). */ export const sliderFillStyle = ( diff --git a/apps/web-foldkit/src/core/components/ui/switch.styles.ts b/apps/web-foldkit/src/core/components/ui/switch.styles.ts index 1ac05aea7..689689abc 100644 --- a/apps/web-foldkit/src/core/components/ui/switch.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/switch.styles.ts @@ -1,37 +1,35 @@ import type { ClassNameValue } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const switchStyles = [ "[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]", "[--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20 [--switch:white]", - "group relative grid cursor-default grid-cols-[1fr_auto] gap-x-6 gap-y-1 disabled:opacity-50 *:data-[slot=indicator]:col-start-2 *:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1 *:data-[slot=indicator]:self-start has-[[slot=description]]:**:data-[slot=label]:font-medium sm:*:data-[slot=indicator]:mt-0.5 *:[[slot=description]]:col-start-1 *:[[slot=description]]:row-start-2", + "group relative grid cursor-default grid-cols-[1fr_auto] gap-x-6 gap-y-1 data-disabled:opacity-50 *:data-[slot=indicator]:col-start-2 *:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1 *:data-[slot=indicator]:self-start has-[[slot=description]]:**:data-[slot=label]:font-medium sm:*:data-[slot=indicator]:mt-0.5 *:[[slot=description]]:col-start-1 *:[[slot=description]]:row-start-2", ] export interface SwitchState { - readonly isHovered: boolean - readonly isFocusVisible: boolean readonly isSelected: boolean readonly isDisabled: boolean } -/** The track: `twMerge(...switchIndicatorStyles(state))`. */ +/** The track: `twMerge(...switchIndicatorStyles(state))`. Hover and focus rings follow the label (`group`). */ export const switchIndicatorStyles = (values: SwitchState): ClassNameValue[] => [ "relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8", "transition duration-200 ease-in-out", "inset-ring inset-ring-input bg-input/30", "forced-colors:outline forced-colors:[--switch-bg:Highlight]", - values.isHovered && "inset-ring-muted-fg/30", - values.isFocusVisible && - "inset-ring-ring/70 selected:inset-ring-ring/30 bg-ring/20 ring-2 ring-ring/20 dark:inset-ring-ring/70", + "group-has-focus-visible:ring-2 group-has-focus-visible:ring-ring/20", + !values.isSelected && + !values.isDisabled && + "group-hover:inset-ring-muted-fg/30 group-has-focus-visible:inset-ring-ring/70 group-has-focus-visible:bg-ring/20", values.isSelected && "inset-ring-(--switch-shadow) bg-(--switch-bg) dark:inset-ring-(--switch-bg-ring) dark:bg-(--switch-bg)", values.isDisabled && - "dark:group-disabled:bg-muted-fg/30 dark:group-disabled:group-selected:inset-ring-muted-fg/30 dark:group-disabled:group-selected:bg-(--switch-bg)", + "dark:group-data-disabled:bg-muted-fg/30 dark:group-data-disabled:group-data-selected:inset-ring-muted-fg/30 dark:group-data-disabled:group-data-selected:bg-(--switch-bg)", ] /** The thumb: `twJoin(...switchThumbStyles(state))`. */ export const switchThumbStyles = (values: Pick<SwitchState, "isSelected">): ClassNameValue[] => [ "pointer-events-none relative inline-block size-4.5 translate-x-0 rounded-full border border-transparent bg-white shadow-sm ring ring-fg/5 transition duration-200 ease-in-out sm:size-3.5", values.isSelected && - "translate-x-4 bg-(--switch) shadow-(--switch-shadow) ring-(--switch-ring) group-disabled:shadow-sm group-disabled:ring-secondary-fg/5 sm:translate-x-3", + "translate-x-4 bg-(--switch) shadow-(--switch-shadow) ring-(--switch-ring) group-data-disabled:shadow-sm group-data-disabled:ring-secondary-fg/5 sm:translate-x-3", ] diff --git a/apps/web-foldkit/src/core/components/ui/tabs.styles.ts b/apps/web-foldkit/src/core/components/ui/tabs.styles.ts index 64efec87a..f18f6eaa8 100644 --- a/apps/web-foldkit/src/core/components/ui/tabs.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/tabs.styles.ts @@ -21,9 +21,9 @@ export const tabsStyles = { : "w-full justify-start [--tab-gutter-x:--spacing(4)] [--tab-gutter-y:--spacing(1.5)]", "relative isolate flex cursor-default items-center whitespace-nowrap font-medium text-sm/6 outline-hidden transition", "px-(--tab-gutter-x) py-(--tab-gutter-y)", - "*:data-[slot=icon]:mr-2 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-muted-fg selected:*:data-[slot=icon]:text-primary-subtle-fg", - "selected:text-primary-subtle-fg text-muted-fg hover:bg-secondary selected:hover:bg-primary-subtle hover:text-fg selected:hover:text-primary-subtle-fg focus:ring-0", - "disabled:opacity-50", + "*:data-[slot=icon]:mr-2 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-muted-fg data-selected:*:data-[slot=icon]:text-primary-subtle-fg", + "data-selected:text-primary-subtle-fg text-muted-fg hover:bg-secondary data-selected:hover:bg-primary-subtle hover:text-fg data-selected:hover:text-primary-subtle-fg focus:ring-0 focus-visible:ring-2 focus-visible:ring-ring/70", + "data-disabled:opacity-50", isLink ? "cursor-pointer" : "cursor-default", ], selectionIndicator: (orientation: Orientation) => [ diff --git a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts index f7d73955c..ba441498b 100644 --- a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts @@ -4,9 +4,9 @@ export const textareaStyles = [ "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 invalid:focus:enabled:hover:border-danger-subtle-fg/80", - "disabled:bg-muted forced-colors:in-disabled:text-[GrayText]", - "in-disabled:bg-muted forced-colors:in-disabled:text-[GrayText]", + "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 data-invalid:focus:enabled:hover:border-danger-subtle-fg/80", + "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/tooltip.styles.ts b/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts index f1b7cd1e8..1c12e0b12 100644 --- a/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts @@ -13,13 +13,13 @@ export const tooltipStyles = tv({ isEntering: { true: [ "fade-in animate-in", - "placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 placement-top:slide-in-from-bottom-1 placement-bottom:slide-in-from-top-1", + "data-[placement=left]:slide-in-from-right-1 data-[placement=right]:slide-in-from-left-1 data-[placement=top]:slide-in-from-bottom-1 data-[placement=bottom]:slide-in-from-top-1", ], }, isExiting: { true: [ "fade-in direction-reverse animate-in", - "placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 placement-top:slide-out-to-bottom-1 placement-bottom:slide-out-to-top-1", + "data-[placement=left]:slide-out-to-right-1 data-[placement=right]:slide-out-to-left-1 data-[placement=top]:slide-out-to-bottom-1 data-[placement=bottom]:slide-out-to-top-1", ], }, }, @@ -30,6 +30,6 @@ export const tooltipStyles = tv({ /** Arrow classes, joined by `twJoin` with the inverse or default fill. */ export const tooltipArrowBase = - "block group-placement-bottom:rotate-180 group-placement-left:-rotate-90 group-placement-right:rotate-90 forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]" + "block group-data-[placement=bottom]:rotate-180 group-data-[placement=left]:-rotate-90 group-data-[placement=right]:rotate-90 forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]" export const tooltipArrowInverse = "fill-fg stroke-transparent" export const tooltipArrowDefault = "fill-overlay stroke-(--tooltip-border)" diff --git a/apps/web-foldkit/src/core/styles/styles.css b/apps/web-foldkit/src/core/styles/styles.css index 709df7727..abae02d56 100644 --- a/apps/web-foldkit/src/core/styles/styles.css +++ b/apps/web-foldkit/src/core/styles/styles.css @@ -3,8 +3,6 @@ @import "./theme.css"; -@plugin 'tailwindcss-react-aria-components'; - /* If you want to use the typography plugin, uncomment the line below. */ diff --git a/apps/web-foldkit/src/gif-picker/view.ts b/apps/web-foldkit/src/gif-picker/view.ts index fd8d8b95f..6f4537cd4 100644 --- a/apps/web-foldkit/src/gif-picker/view.ts +++ b/apps/web-foldkit/src/gif-picker/view.ts @@ -49,7 +49,6 @@ const categoryChips = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: category.query, [ h.Attribute("type", "button"), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("tabindex", "0"), h.OnClick(toMessage(Message.ClickedCategory({ category: category.category }))), @@ -78,7 +77,6 @@ const grid = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) [ h.Role("grid"), h.Attribute("aria-label", "GIF results"), - h.Attribute("data-rac", ""), h.Attribute("data-layout", "grid"), h.Attribute("tabindex", "0"), h.Class("flex-1 columns-2 gap-2 overflow-y-auto overflow-x-hidden px-3 scrollbar-thin relative"), @@ -92,7 +90,6 @@ const grid = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) gif.slug, [ h.Role("row"), - h.Attribute("data-rac", ""), h.Attribute("tabindex", "-1"), h.Class( "relative mb-2 cursor-pointer break-inside-avoid overflow-hidden rounded-md bg-muted/60 outline-none transition-all hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary ", diff --git a/apps/web-foldkit/src/overlay/modal/bot-avatar-upload.ts b/apps/web-foldkit/src/overlay/modal/bot-avatar-upload.ts index 5bfb113de..6c8c78048 100644 --- a/apps/web-foldkit/src/overlay/modal/bot-avatar-upload.ts +++ b/apps/web-foldkit/src/overlay/modal/bot-avatar-upload.ts @@ -25,7 +25,6 @@ export const botAvatarUpload = <Message>(h: HtmlBuilder<Message>, options: BotAv h.div( [ h.Class("rounded-xl focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"), - h.DataAttribute("rac", ""), ], [ h.div( diff --git a/apps/web-foldkit/src/page/chat/overlay-views.ts b/apps/web-foldkit/src/page/chat/overlay-views.ts index 89987a030..d0a4fad29 100644 --- a/apps/web-foldkit/src/page/chat/overlay-views.ts +++ b/apps/web-foldkit/src/page/chat/overlay-views.ts @@ -71,7 +71,6 @@ export const messageToolbarOverlay = <M>( ...attributes, h.Attribute("aria-label", "More actions"), h.Class(menuTriggerClassName("p-1.5! rounded-md hover:bg-secondary")), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("data-slot", "menu-trigger"), h.Attribute("tabindex", "0"), diff --git a/apps/web-foldkit/src/page/chat/pinned.ts b/apps/web-foldkit/src/page/chat/pinned.ts index 19cfdee5c..429ffb6e2 100644 --- a/apps/web-foldkit/src/page/chat/pinned.ts +++ b/apps/web-foldkit/src/page/chat/pinned.ts @@ -17,7 +17,6 @@ const authorAvatar = <M>(h: HtmlBuilder<M>, pin: PinnedInfo): Html => [ h.Attribute("aria-expanded", "false"), 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"), diff --git a/apps/web-foldkit/src/page/chat/tab-bar.ts b/apps/web-foldkit/src/page/chat/tab-bar.ts index dca865d5b..4686a9dcf 100644 --- a/apps/web-foldkit/src/page/chat/tab-bar.ts +++ b/apps/web-foldkit/src/page/chat/tab-bar.ts @@ -1,107 +1,27 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { twMerge } from "tailwind-merge" -import { cx } from "~/utils/cx" import { IconFolders, IconMsgs } from "../../icons" +import { tabStrip, tabView } from "../../ui/tab-strip" import { type ChatTab, Message } from "./channel/page" -/** `ChatTabBar`: Messages and Files tabs; selecting one navigates like the legacy `onSelectionChange`. */ - -/** `Tab` from `components/ui/tabs.tsx`, horizontal orientation. */ -const tabView = <M>( - h: HtmlBuilder<M>, - options: { id: string; label: string; icon: Html; isSelected: boolean; onClick: M }, -) => - h.div( - [ - ...(options.isSelected - ? [h.Attribute("aria-selected", "true"), h.Attribute("data-selected", "true")] - : [h.Attribute("aria-selected", "false")]), - h.Class( - twMerge( - twMerge( - "group/tab rounded-lg [--tab-gutter:var(--tab-gutter-x)]", - "[--tab-gutter-x:--spacing(2.5)] [--tab-gutter-y:--spacing(1)] first:-ml-(--tab-gutter) last:-mr-(--tab-gutter)", - "relative isolate flex cursor-default items-center whitespace-nowrap font-medium text-sm/6 outline-hidden transition", - "px-(--tab-gutter-x) py-(--tab-gutter-y)", - "*:data-[slot=icon]:mr-2 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-muted-fg selected:*:data-[slot=icon]:text-primary-subtle-fg", - "selected:text-primary-subtle-fg text-muted-fg hover:bg-secondary selected:hover:bg-primary-subtle hover:text-fg selected:hover:text-primary-subtle-fg focus:ring-0", - "disabled:opacity-50", - "cursor-default", - ), - ), - ), - h.Attribute("data-key", options.id), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("data-slot", "tab"), - h.Attribute("role", "tab"), - h.Attribute("tabindex", options.isSelected ? "0" : "-1"), - h.OnClick(options.onClick), - ], - [ - options.icon, - options.label, - ...(options.isSelected - ? [ - h.div([ - h.Class( - twMerge( - "absolute bg-primary-subtle-fg transition-[translate,width,height] duration-200", - "right-(--tab-gutter-x) -bottom-[calc(var(--tab-gutter-y)+1px)] left-(--tab-gutter-x) h-[2px]", - ), - ), - h.Attribute("data-rac", ""), - h.Attribute("data-slot", "selected-indicator"), - ]), - ] - : []), - ], - ) - +/** `ChatTabBar`: Messages and Files tabs; selecting one navigates. */ export const chatTabBarView = <M>( tab: ChatTab, toParentMessage: (message: Message) => M, h: HtmlBuilder<M>, ): Html => - h.div( - [ - h.Class(cx("flex-col", "group/tabs flex gap-4 forced-color-adjust-none")), - h.Attribute("data-orientation", "horizontal"), - h.Attribute("data-rac", ""), - ], - [ - h.div( - [ - h.Attribute("aria-orientation", "horizontal"), - h.Class( - twMerge([ - "[--tab-list-gutter:--spacing(1)]", - "relative flex forced-color-adjust-none", - "flex-row gap-x-(--tab-list-gutter) rounded-(--tab-list-rounded) border-b py-(--tab-list-gutter)", - "px-4", - ]), - ), - h.Attribute("data-orientation", "horizontal"), - h.Attribute("data-rac", ""), - h.Attribute("data-slot", "tab-list"), - h.Attribute("role", "tablist"), - ], - [ - tabView(h, { - id: "messages", - label: "Messages", - icon: IconMsgs(h, { className: "size-4", attributes: { "data-slot": "icon" } }), - isSelected: tab === "messages", - onClick: toParentMessage(Message.ClickedTab({ tab: "messages" })), - }), - tabView(h, { - id: "files", - label: "Files", - icon: IconFolders(h, { className: "size-4", attributes: { "data-slot": "icon" } }), - isSelected: tab !== "messages", - onClick: toParentMessage(Message.ClickedTab({ tab: "files" })), - }), - ], - ), - ], - ) + tabStrip(h, { tabListClassName: "px-4" }, [ + tabView(h, { + id: "messages", + label: "Messages", + icon: IconMsgs(h, { className: "size-4", attributes: { "data-slot": "icon" } }), + isSelected: tab === "messages", + attributes: [h.OnClick(toParentMessage(Message.ClickedTab({ tab: "messages" })))], + }), + tabView(h, { + id: "files", + label: "Files", + icon: IconFolders(h, { className: "size-4", attributes: { "data-slot": "icon" } }), + isSelected: tab !== "messages", + attributes: [h.OnClick(toParentMessage(Message.ClickedTab({ tab: "files" })))], + }), + ]) diff --git a/apps/web-foldkit/src/page/home/view.ts b/apps/web-foldkit/src/page/home/view.ts index fcaf7581d..983cbf668 100644 --- a/apps/web-foldkit/src/page/home/view.ts +++ b/apps/web-foldkit/src/page/home/view.ts @@ -58,7 +58,7 @@ const memberMenuTrigger = menuTriggerClassName( cn( "inline-flex size-8 items-center justify-center rounded-lg border border-transparent hover:border-border hover:bg-secondary", - "pressed:bg-secondary group-hover:border-border", + "data-pressed:bg-secondary group-hover:border-border", ), ), ), @@ -165,7 +165,7 @@ const memberRow = ( name: fullName, }), className: - "hidden border-transparent pressed:bg-muted group-hover:border-border sm:inline-flex", + "hidden border-transparent data-pressed:bg-muted group-hover:border-border sm:inline-flex", }, [IconMsgs(h, { attributes: { "data-slot": "icon" } })], ), diff --git a/apps/web-foldkit/src/page/my-settings/appearance/view.ts b/apps/web-foldkit/src/page/my-settings/appearance/view.ts index 1f6db63b8..c30ad12a6 100644 --- a/apps/web-foldkit/src/page/my-settings/appearance/view.ts +++ b/apps/web-foldkit/src/page/my-settings/appearance/view.ts @@ -149,7 +149,6 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, "ring-2 ring-ring ring-offset-2 ring-offset-bg", ), ), - h.DataAttribute("rac", ""), h.Id(`color-${swatch.hex}`), h.Role("img"), h.Style({ backgroundColor: swatch.hex, forcedColorAdjust: "none" }), diff --git a/apps/web-foldkit/src/page/my-settings/desktop/view.ts b/apps/web-foldkit/src/page/my-settings/desktop/view.ts index 47d1d7c17..f827dd939 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/view.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/view.ts @@ -229,7 +229,6 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, isDisabled: model.autostartEnabled === null, onChange: (isSelected) => Message.ToggledAutostart({ isSelected }), - interaction: interaction.wiring(model), }, "Open at login", ), diff --git a/apps/web-foldkit/src/page/my-settings/notifications/view.ts b/apps/web-foldkit/src/page/my-settings/notifications/view.ts index f7d556a4d..7ab2b52d3 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/view.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/view.ts @@ -39,7 +39,6 @@ const soundSettings = ( id: "notification-sounds", isSelected: enabled, onChange: (isSelected) => Message.ToggledSounds({ isSelected }), - interaction: wiring, }, "Enable notification sounds", ), @@ -202,7 +201,6 @@ const doNotDisturb = (h: HtmlBuilder<Message>, model: Model, wiring: Interaction id: "do-not-disturb", isSelected: settings?.doNotDisturb ?? false, onChange: (isSelected) => Message.ToggledDoNotDisturb({ isSelected }), - interaction: wiring, }, "Enable do not disturb", ), @@ -229,7 +227,6 @@ const doNotDisturb = (h: HtmlBuilder<Message>, model: Model, wiring: Interaction id: "show-quiet-hours", isSelected: settings?.showQuietHoursInStatus ?? true, onChange: (isSelected) => Message.ToggledShowQuietHours({ isSelected }), - interaction: wiring, }, "Show quiet hours in status", ), diff --git a/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts b/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts index ea9fa724a..aa67728cd 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts @@ -76,7 +76,6 @@ export const profilePictureUpload = ( h.Class( "rounded-xl focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2", ), - h.DataAttribute("rac", ""), ...(model.isDropTarget ? [h.DataAttribute("drop-target", "true")] : []), ...(model.isUploading ? [h.DataAttribute("disabled", "true")] @@ -170,7 +169,6 @@ export const profilePictureUpload = ( h.Type("file"), h.Attribute("accept", ALLOWED_TYPES), h.Class(""), - h.DataAttribute("rac", ""), h.Style({ display: "none" }), h.OnFileChange((files) => Message.SelectedAvatarFiles({ files })), ]), diff --git a/apps/web-foldkit/src/page/onboarding/steps/theme.ts b/apps/web-foldkit/src/page/onboarding/steps/theme.ts index f31d2e725..9e5318f4e 100644 --- a/apps/web-foldkit/src/page/onboarding/steps/theme.ts +++ b/apps/web-foldkit/src/page/onboarding/steps/theme.ts @@ -49,7 +49,6 @@ const ariaRadio = ( h.label( [ h.Class(options.className), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), ...(options.isSelected ? [h.DataAttribute("selected", "true")] : []), ], @@ -82,7 +81,6 @@ const radioGroup = (h: HtmlBuilder<Message>, ariaLabel: string, className: strin h.Attribute("aria-orientation", "vertical"), h.Class(className), h.DataAttribute("orientation", "vertical"), - h.DataAttribute("rac", ""), h.Role("radiogroup"), ], children, @@ -115,7 +113,6 @@ const brandColors = (h: HtmlBuilder<Message>, theme: ThemeState): Html => isSelected && "ring-2 ring-ring ring-offset-2 ring-offset-bg", ), ), - h.DataAttribute("rac", ""), h.Id(`color-${swatch.hex}`), h.Role("img"), h.Attribute( diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts index 1120d16ca..8051bf55d 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts @@ -154,7 +154,6 @@ export const channelLinkRow = ( "inline-flex size-7 items-center justify-center rounded-lg text-muted-fg hover:bg-secondary hover:text-fg", ), ), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("data-slot", "menu-trigger"), h.Attribute("tabindex", "0"), diff --git a/apps/web-foldkit/src/page/settings/chat-sync/view.ts b/apps/web-foldkit/src/page/settings/chat-sync/view.ts index ee5dcc801..908b04ae4 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/view.ts @@ -71,7 +71,6 @@ const addConnectionDropdown = ( [ ...attributes, h.Class(menuTriggerClassName(buttonStyles({ intent: "primary", size: "md" }))), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("data-slot", "menu-trigger"), h.Attribute("tabindex", "0"), diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts index d6414596b..89ab99a57 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts @@ -31,7 +31,6 @@ const fileInput = (h: HtmlBuilder<Message>, id: string): Html => h.Type("file"), h.Attribute("accept", ALLOWED_EMOJI_TYPES.join(",")), h.Attribute("class", ""), - h.DataAttribute("rac", ""), h.Attribute("style", "display: none;"), h.OnFileChange((files) => Message.SelectedFiles({ files })), ]) @@ -40,7 +39,6 @@ const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): H h.div( [ h.Class("rounded-lg focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"), - h.DataAttribute("rac", ""), ...(model.isDropTarget ? [h.DataAttribute("drop-target", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] @@ -75,7 +73,6 @@ const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): H isDisabled && "pointer-events-none opacity-50", ), ), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.Tabindex(0), h.Type("button"), diff --git a/apps/web-foldkit/src/picker-popover/popover.ts b/apps/web-foldkit/src/picker-popover/popover.ts index 962ab413e..af1516929 100644 --- a/apps/web-foldkit/src/picker-popover/popover.ts +++ b/apps/web-foldkit/src/picker-popover/popover.ts @@ -77,9 +77,7 @@ const overlay = <M>(h: HtmlBuilder<M>, inputs: PopoverInputs<M>): Html => { h.div( [ h.Attribute("aria-labelledby", triggerId(id)), - h.Class("react-aria-Popover"), h.Attribute("data-popover", ""), - h.Attribute("data-rac", ""), h.Attribute("data-trigger", "DialogTrigger"), h.Attribute("dir", "ltr"), h.Id(popoverId(id)), @@ -95,7 +93,6 @@ const overlay = <M>(h: HtmlBuilder<M>, inputs: PopoverInputs<M>): Html => { [ h.Attribute("aria-label", inputs.ariaLabel), h.Class("rounded-lg"), - h.Attribute("data-rac", ""), h.Id(dialogId(id)), h.Role("dialog"), h.Attribute("tabindex", "-1"), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/items.ts b/apps/web-foldkit/src/shell/channels-sidebar/items.ts index ccf18c7ba..b08071232 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/items.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/items.ts @@ -193,7 +193,6 @@ const statusEmoji = <M>(h: HtmlBuilder<M>, presence: Presence | undefined): Html ), h.Attribute("type", "button"), h.Attribute("tabindex", "0"), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ], [presence.statusEmoji], diff --git a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts index 58df8df92..68bd3a4dc 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts @@ -19,7 +19,6 @@ export const dragDescription = <M>(h: HtmlBuilder<M>): Html => const menuTriggerAttributes = <M>(h: HtmlBuilder<M>) => [ h.Attribute("aria-haspopup", "true"), h.Attribute("aria-expanded", "false"), - h.Attribute("data-rac", ""), ] /** The row actions trigger in channel and DM items. */ @@ -43,7 +42,7 @@ export const sectionPlusButton = <M>(h: HtmlBuilder<M>, attributes: ReadonlyArra intent: "plain", isCircle: true, size: "sq-xs", - attributes: [h.Attribute("data-rac", ""), ...attributes], + attributes: [...attributes], }, [IconPlus(h)], ) @@ -112,7 +111,6 @@ export const treeSection = <M>(h: HtmlBuilder<M>, options: TreeSectionOptions): ), ...(options.allowsDragging ? [h.Attribute("data-allows-dragging", "true")] : []), ...(isEmpty ? [h.Attribute("data-empty", "true")] : []), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "sidebar-section-inner"), h.Id(options.id), h.Attribute("role", "treegrid"), @@ -166,7 +164,6 @@ export const treeRow = <M>(h: HtmlBuilder<M>, options: TreeRowOptions<M>): Html ...(options.allowsDragging ? [h.Attribute("data-allows-dragging", "true")] : []), h.Attribute("data-key", options.key), h.Attribute("data-level", "1"), - h.Attribute("data-rac", ""), ...(options.allowsDragging ? [h.Attribute("draggable", "true")] : []), h.Id(`${options.treeId}-${options.key}`), h.Attribute("role", "row"), @@ -209,7 +206,6 @@ const dragButton = <M>( ] : []), h.Class("sr-only"), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("slot", "drag"), h.Style({ pointerEvents: "none" }), diff --git a/apps/web-foldkit/src/shell/mobile.ts b/apps/web-foldkit/src/shell/mobile.ts index 5c93c5e19..ee2126d42 100644 --- a/apps/web-foldkit/src/shell/mobile.ts +++ b/apps/web-foldkit/src/shell/mobile.ts @@ -136,7 +136,7 @@ export const mobileSidebarSheet = <Message>( restoresFocusToPrevious: true, overlayAttributes: { "data-slot": "sidebar", "data-intent": "default" }, className: - "w-(--sidebar-width) entering:blur-in exiting:blur-out [--sidebar-width:18rem] has-data-[slot=calendar]:[--sidebar-width:23rem]", + "w-(--sidebar-width) data-entering:blur-in data-exiting:blur-out [--sidebar-width:18rem] has-data-[slot=calendar]:[--sidebar-width:23rem]", }, toParentMessage: options.toMessage, }) diff --git a/apps/web-foldkit/src/ui/aria-radio.ts b/apps/web-foldkit/src/ui/aria-radio.ts index 1426e04b7..7027b4e96 100644 --- a/apps/web-foldkit/src/ui/aria-radio.ts +++ b/apps/web-foldkit/src/ui/aria-radio.ts @@ -61,8 +61,7 @@ export const ariaRadioGroup = <Message>( } return h.label( [ - h.Class(part.className ?? "react-aria-Radio"), - h.DataAttribute("rac", ""), + ...(part.className === undefined ? [] : [h.Class(part.className)]), h.DataAttribute("react-aria-pressable", "true"), ...(isSelected ? [h.DataAttribute("selected", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), @@ -120,8 +119,7 @@ export const ariaRadioGroup = <Message>( return h.div( [ - h.Class(options.className ?? "react-aria-RadioGroup"), - h.DataAttribute("rac", ""), + ...(options.className === undefined ? [] : [h.Class(options.className)]), h.DataAttribute("orientation", "vertical"), h.Role("radiogroup"), h.Id(options.id), diff --git a/apps/web-foldkit/src/ui/aria/interaction.ts b/apps/web-foldkit/src/ui/aria/interaction.ts index c35259ea4..a3fee9d25 100644 --- a/apps/web-foldkit/src/ui/aria/interaction.ts +++ b/apps/web-foldkit/src/ui/aria/interaction.ts @@ -6,9 +6,9 @@ import { modifyFields } from "foldkit/struct" /** * React Aria's useHover, usePress and useFocusRing/useFocusVisible, as one Submodel. - * Components read a target's state with `stateOf` and render it with the same - * `data-hovered`/`data-pressed`/`data-focused`/`data-focus-visible` attributes React Aria - * emits, so the shared `tailwindcss-react-aria-components` variants style both apps alike. + * Components read a target's state with `stateOf` and render it as `data-hovered`/`data-pressed`/ + * `data-focused`/`data-focus-visible` attributes. Styles use native `hover:`/`focus-visible:` and + * Tailwind's `data-*:` variants (e.g. `data-pressed:`) for state CSS cannot see. * One Model serves a whole page: at most one target is hovered, pressed or focused at a time. */ diff --git a/apps/web-foldkit/src/ui/button.ts b/apps/web-foldkit/src/ui/button.ts index 931ff13d8..d94b971df 100644 --- a/apps/web-foldkit/src/ui/button.ts +++ b/apps/web-foldkit/src/ui/button.ts @@ -4,7 +4,7 @@ import { twMerge } from "tailwind-merge" import { buttonStyles } from "~/components/ui/button.styles" import * as Interaction from "./aria/interaction" -/** Port of `components/ui/button.tsx` (React Aria Button). */ +/** Button: the tv() variants over a native `<button>`. */ export type ButtonVariants = VariantProps<typeof buttonStyles> export const buttonClassName = (variants: ButtonVariants, className?: string) => @@ -25,10 +25,7 @@ export interface AriaButtonOptions<Message> { /** React Aria `preventFocusOnPress`: the button never reports focus. */ readonly preventFocusOnPress?: boolean readonly onPress?: Message - /** - * Hover, press and focus-visible state from the page's interaction Submodel. Without it an - * enabled button omits `data-rac`, so the shared variants fall back to native `:hover`/`:focus-visible`. - */ + /** Press and focus state (`data-pressed`, ...) from the page's interaction Submodel; hover and focus rings are native CSS. */ readonly interaction?: { readonly wiring: Interaction.Wiring<Message>; readonly target: string } readonly attributes?: ReadonlyArray<Attribute<Message> | ChildAttribute> } @@ -66,7 +63,6 @@ export const ariaButton = <Message>( ...(isPending ? [h.AriaDisabled(true), h.DataAttribute("pending", "true")] : []), ...(interaction ? [ - h.DataAttribute("rac", ""), ...Interaction.handlers(h, interaction.wiring, interaction.target, { isHoverDisabled: isDisabled || isPending, isPressDisabled: isDisabled || isPending, @@ -78,10 +74,7 @@ export const ariaButton = <Message>( }), ...Interaction.pressStyleAttributes(h, interaction.wiring.model, interaction.target), ] - : // RAC never hovers, presses or focuses a disabled button, unlike native `:hover`. - isDisabled - ? [h.DataAttribute("rac", "")] - : []), + : []), ...(options.onPress !== undefined && !isDisabled && !isPending ? [h.OnClick(options.onPress)] : []), diff --git a/apps/web-foldkit/src/ui/calendar-view.ts b/apps/web-foldkit/src/ui/calendar-view.ts index a26f7d6d5..4ae85c7f2 100644 --- a/apps/web-foldkit/src/ui/calendar-view.ts +++ b/apps/web-foldkit/src/ui/calendar-view.ts @@ -152,7 +152,6 @@ const cell = (h: HtmlBuilder<Message>, model: Model, date: D.CalendarDate): Html ...flag("data-disabled", isDisabled), ...flag("data-outside-month", isOutside), ...flag("data-outside-visible-range", isOutside), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ...flag("data-selected", isSelected), ...flag("data-today", isToday), @@ -177,7 +176,6 @@ const grid = (h: HtmlBuilder<Message>, model: Model, label: string): Html => [ h.AriaLabel(label), h.Attribute("cellpadding", "0"), - h.Class("react-aria-CalendarGrid"), h.Id(gridId(model.id)), h.Role("grid"), h.OnKeyDownPreventDefault((key) => @@ -186,7 +184,7 @@ const grid = (h: HtmlBuilder<Message>, model: Model, label: string): Html => ], [ h.thead( - [h.AriaHidden(true), h.Class("react-aria-CalendarGridHeader")], + [h.AriaHidden(true)], [ h.tr( [], @@ -197,7 +195,7 @@ const grid = (h: HtmlBuilder<Message>, model: Model, label: string): Html => ], ), h.tbody( - [h.Class("react-aria-CalendarGridBody")], + [], Array.map(D.monthWeeks(model.focusedDate), (week) => h.tr( [], @@ -214,8 +212,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view return h.div( [ h.AriaLabel(label), - h.Class("react-aria-Calendar"), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "calendar"), h.Id(model.id), h.Role("application"), diff --git a/apps/web-foldkit/src/ui/checkbox.scene.test.ts b/apps/web-foldkit/src/ui/checkbox.scene.test.ts index 47972c6f1..f1259d457 100644 --- a/apps/web-foldkit/src/ui/checkbox.scene.test.ts +++ b/apps/web-foldkit/src/ui/checkbox.scene.test.ts @@ -2,10 +2,10 @@ import * as Scene from "foldkit/scene" import type { HtmlBuilder } from "foldkit/html" import { describe, test } from "vitest" -import { init, Message, type Model, page, update, wiring } from "../test/kit-forms-fixtures" -import { checkbox, checkboxGroup, type CheckboxOptions } from "./checkbox" +import { init, Message, type Model, page, update } from "../test/kit-forms-fixtures" +import { checkbox, type CheckboxOptions } from "./checkbox" -/** Checkbox through a parent that owns the value and the interaction Submodel. */ +/** Checkbox through a parent that owns the value. */ const view = (options: Partial<CheckboxOptions<Message>> = {}) => @@ -17,7 +17,6 @@ const view = id: "notify", isSelected: model.isSelected, onChange: (isSelected) => Message.ChangedSelection({ isSelected }), - interaction: wiring(model), ...options, }, "Notify me", @@ -60,36 +59,23 @@ describe("checkbox scene", () => { ) }) - test("hovering and pressing the label shows hover and pressed state", () => { + test("hover and the keyboard focus ring are native CSS on the label, with no wiring", () => { Scene.scene( { update, view: view() }, Scene.given(init()), - Scene.hover(control), - Scene.expect(control).toHaveAttr("data-hovered", "true"), - Scene.pointerDown(control), - Scene.expect(control).toHaveAttr("data-pressed", "true"), - Scene.expect(control).toHaveAttr("style", "user-select: none;"), + Scene.expect(control).toHaveClass("group"), + Scene.expect(Scene.selector(`[data-slot="indicator"]`)).toHaveClass("group-hover:inset-ring-muted-fg/30"), + Scene.expect(Scene.selector(`[data-slot="indicator"]`)).toHaveClass("group-has-focus-visible:ring-3"), ) }) - test("focusing the input without a pointer press shows the focus ring on the control", () => { - Scene.scene( - { update, view: view() }, - Scene.given(init()), - Scene.focus(box), - Scene.expect(control).toHaveAttr("data-focused", "true"), - Scene.expect(control).toHaveAttr("data-focus-visible", "true"), - ) - }) - - test("a disabled checkbox is disabled and ignores hover", () => { + test("a disabled checkbox is disabled and ignores clicks", () => { Scene.scene( { update, view: view({ isDisabled: true }) }, Scene.given(init()), Scene.expect(box).toBeDisabled(), Scene.expect(control).toHaveAttr("data-disabled", "true"), Scene.expect(control).not.toHaveHandler("click"), - Scene.expect(control).not.toHaveHandler("mouseenter"), ) }) @@ -102,29 +88,4 @@ describe("checkbox scene", () => { Scene.expect(control).toHaveAttr("data-invalid", "true"), ) }) - - test("a checkbox group is a labelled group whose items follow the group value", () => { - const groupView = (model: Model, h: HtmlBuilder<Message>) => - page(h, [ - checkboxGroup( - h, - { - id: "channels", - value: model.isSelected ? ["email"] : [], - onChange: (value) => Message.ChangedSelection({ isSelected: value.includes("email") }), - interaction: wiring(model), - }, - (parts) => [parts.label(["Channels"]), parts.checkbox("email", "Email"), parts.checkbox("sms", "SMS")], - ), - ]) - Scene.scene( - { update, view: groupView }, - Scene.given(init()), - Scene.expect(Scene.role("group", { name: "Channels" })).toExist(), - Scene.click(Scene.text("Email")), - Scene.expect(Scene.selector("#channels-email")).toBeChecked(), - Scene.expect(Scene.selector("#channels-email")).toHaveAttr("value", "email"), - Scene.expect(Scene.selector("#channels-sms")).not.toBeChecked(), - ) - }) }) diff --git a/apps/web-foldkit/src/ui/checkbox.ts b/apps/web-foldkit/src/ui/checkbox.ts index 9e603aae4..037dd6a48 100644 --- a/apps/web-foldkit/src/ui/checkbox.ts +++ b/apps/web-foldkit/src/ui/checkbox.ts @@ -2,21 +2,15 @@ import { Schema } from "effect" import { CustomElement } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" -import { - checkboxGroupStyles, - checkboxIndicatorStyles, - checkboxLayoutStyles, - checkboxStyles, -} from "~/components/ui/checkbox.styles" +import { checkboxIndicatorStyles, checkboxLayoutStyles, checkboxStyles } from "~/components/ui/checkbox.styles" import { IconCheck, IconMinus } from "../icons" import * as Collection from "./aria/collection" -import * as Interaction from "./aria/interaction" import * as Field from "./field" /** - * Port of `components/ui/checkbox.tsx` (React Aria Checkbox). The `<label>` takes hover and press, - * the visually hidden `<input>` takes focus. As in useToggle, pressing the label prevents the native - * click, toggles, and focuses the input. + * A checkbox: the `<label>` takes the pointer, the visually hidden `<input>` takes focus. Pressing the + * label prevents the native click, toggles, and focuses the input. Hover and the keyboard focus ring + * are native CSS on the label (`group-hover`, `group-has-focus-visible`). */ export const visuallyHiddenStyle = "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;" @@ -32,13 +26,13 @@ const indeterminateProperty = CustomElement.define({ }) export interface CheckboxOptions<Message> { - /** Element id of the input and the interaction target. */ + /** Element id of the input. */ readonly id: string readonly isSelected: boolean readonly isIndeterminate?: boolean readonly isDisabled?: boolean readonly isInvalid?: boolean - /** Form value; inside a CheckboxGroup this is the item's value. */ + /** Form value. */ readonly value?: string readonly describedBy?: string /** React Aria slot, e.g. `selection` for a GridList row's checkbox. */ @@ -49,7 +43,6 @@ export interface CheckboxOptions<Message> { readonly stopsClickPropagation?: boolean readonly onChange?: (isSelected: boolean) => Message readonly className?: string - readonly interaction?: Interaction.Wiring<Message> } export const checkbox = <Message>( @@ -60,8 +53,6 @@ export const checkbox = <Message>( const isDisabled = options.isDisabled ?? false const isInvalid = options.isInvalid ?? false const isIndeterminate = options.isIndeterminate ?? false - const interaction = options.interaction - const state = interaction ? Interaction.stateOf(interaction.model, options.id) : Interaction.idleState const toggle = options.onChange?.(!options.isSelected) const indicator = isIndeterminate ? IconMinus(h, { attributes: { "data-slot": "check-indicator" } }) @@ -74,7 +65,6 @@ export const checkbox = <Message>( return h.label( [ h.Class(twMerge(twMerge(checkboxStyles), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.DataAttribute("slot", "control"), ...(options.slot === undefined ? [] : [h.Attribute("slot", options.slot)]), @@ -82,17 +72,6 @@ export const checkbox = <Message>( ...(isIndeterminate ? [h.DataAttribute("indeterminate", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), ...(isInvalid ? [h.DataAttribute("invalid", "true")] : []), - ...(interaction - ? [ - ...Interaction.handlers(h, interaction, options.id, { - isHoverDisabled: isDisabled, - isPressDisabled: isDisabled, - isFocusDisabled: true, - }), - ...Interaction.stateAttributes(h, state), - ...Interaction.pressStyleAttributes(h, interaction.model, options.id), - ] - : []), ...(toggle === undefined || isDisabled ? [] : [ @@ -123,13 +102,6 @@ export const checkbox = <Message>( // Foldkit mirrors the live value into the `checked` attribute; React keeps the initial one. h.Checked(options.isSelected), indeterminateProperty.withMessage(h).Indeterminate(isIndeterminate), - ...(interaction - ? Interaction.handlers(h, interaction, options.id, { - isHoverDisabled: true, - isPressDisabled: true, - isFocusDisabled: isDisabled, - }) - : []), // Space toggles natively; the Model follows like React Aria's onChange. ...(toggle === undefined ? [] : [h.OnChange(() => toggle)]), ]), @@ -146,7 +118,6 @@ export const checkbox = <Message>( checkboxIndicatorStyles({ isSelected: options.isSelected, isIndeterminate, - isFocusVisible: state.isFocusVisible, isInvalid, }), ), @@ -161,97 +132,3 @@ export const checkbox = <Message>( ) } -export interface CheckboxGroupOptions<Message> { - readonly id: string - readonly value: ReadonlyArray<string> - readonly onChange?: (value: ReadonlyArray<string>) => Message - readonly isInvalid?: boolean - readonly isDisabled?: boolean - readonly className?: string - readonly interaction?: Interaction.Wiring<Message> -} - -export interface CheckboxGroupParts<Message> { - readonly label: (children: Array<Html | string>, options?: Field.PartOptions<Message>) => Html - readonly description: (children: Array<Html | string>, options?: Field.PartOptions<Message>) => Html - readonly fieldError: (children: Array<Html | string>, options?: Field.PartOptions<Message>) => Html - /** A checkbox whose selection, invalid and disabled state come from the group. */ - readonly checkbox: ( - value: string, - children: string | Array<Html>, - options?: { readonly className?: string }, - ) => Html -} - -/** React Aria CheckboxGroup: `role="group"`, labelled by its Label (rendered as a span). */ -export const checkboxGroup = <Message>( - h: HtmlBuilder<Message>, - options: CheckboxGroupOptions<Message>, - render: (parts: CheckboxGroupParts<Message>) => Array<Html>, -): Html => { - const ids = { - label: `${options.id}-label`, - description: `${options.id}-description`, - error: `${options.id}-error`, - } - const isInvalid = options.isInvalid ?? false - const onChange = options.onChange - const used = new Set<"label" | "description">() - const describedBy = (hasDescription: boolean) => - [...(hasDescription ? [ids.description] : []), ...(isInvalid ? [ids.error] : [])].join(" ") || - undefined - const partsFor = (hasDescription: boolean): CheckboxGroupParts<Message> => ({ - label: (children, part = {}) => { - used.add("label") - return Field.label(h, { ...part, id: ids.label, elementType: "span" }, children) - }, - description: (children, part = {}) => { - used.add("description") - return Field.description(h, { ...part, id: ids.description }, children) - }, - fieldError: (children, part = {}) => - isInvalid ? Field.fieldError(h, { ...part, id: ids.error }, children) : null, - checkbox: (value, children, part = {}) => { - const isSelected = options.value.includes(value) - return checkbox( - h, - { - id: `${options.id}-${value}`, - isSelected, - value, - isInvalid, - isDisabled: options.isDisabled, - describedBy: isInvalid ? describedBy(hasDescription) : undefined, - onChange: onChange - ? (next) => - onChange( - next - ? [...options.value, value] - : options.value.filter((item) => item !== value), - ) - : undefined, - interaction: options.interaction, - className: part.className, - }, - children, - ) - }, - }) - render(partsFor(true)) - const children = render(partsFor(used.has("description"))) - const groupDescribedBy = describedBy(used.has("description")) - return h.div( - [ - h.Class(twMerge(twMerge(checkboxGroupStyles), options.className)), - h.DataAttribute("rac", ""), - h.DataAttribute("slot", "control"), - h.Role("group"), - h.Id(options.id), - ...(used.has("label") ? [h.AriaLabelledBy(ids.label)] : []), - ...(groupDescribedBy === undefined ? [] : [h.AriaDescribedBy(groupDescribedBy)]), - ...(isInvalid ? [h.DataAttribute("invalid", "true")] : []), - ...(options.isDisabled ? [h.DataAttribute("disabled", "true")] : []), - ], - children, - ) -} diff --git a/apps/web-foldkit/src/ui/choice-box.ts b/apps/web-foldkit/src/ui/choice-box.ts index eed30dd29..d5fae9ea2 100644 --- a/apps/web-foldkit/src/ui/choice-box.ts +++ b/apps/web-foldkit/src/ui/choice-box.ts @@ -260,7 +260,6 @@ export const choiceBox = <ParentMessage>( send(Message.ToggledSelectionCheckbox({ key: item.key })), }), stopsClickPropagation: true, - interaction, }, [], ), @@ -281,7 +280,6 @@ export const choiceBox = <ParentMessage>( ), h.Id(id), h.Role("row"), - h.DataAttribute("rac", ""), h.DataAttribute("collection", model.id), h.DataAttribute("key", item.key), h.DataAttribute("selection-mode", model.selectionMode), @@ -330,7 +328,6 @@ export const choiceBox = <ParentMessage>( h.Role("grid"), h.AriaLabel(options.ariaLabel), ...(model.selectionMode === "multiple" ? [h.AriaMultiSelectable(true)] : []), - h.DataAttribute("rac", ""), h.DataAttribute("collection", model.id), h.DataAttribute("layout", columns === 1 ? "stack" : "grid"), h.DataAttribute("slot", "control"), diff --git a/apps/web-foldkit/src/ui/combo-box-view.ts b/apps/web-foldkit/src/ui/combo-box-view.ts index 60c7048fc..9aa0b5ef0 100644 --- a/apps/web-foldkit/src/ui/combo-box-view.ts +++ b/apps/web-foldkit/src/ui/combo-box-view.ts @@ -130,7 +130,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.Class(twMerge(twMerge(fieldStyles()), viewInputs.className)), ...flag("data-focused", model.isFocused), ...flag("data-open", isOpen), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "control"), ], [ @@ -255,7 +254,6 @@ const listPopover = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBu ), ), h.Attribute("data-popover", ""), - h.Attribute("data-rac", ""), h.Attribute("data-trigger", "ComboBox"), h.Attribute("dir", "ltr"), ], @@ -293,7 +291,6 @@ const listbox = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde h.Class(twMerge(twMerge(comboBoxListBoxBase), viewInputs.listBoxClassName)), h.Attribute("data-layout", "stack"), h.Attribute("data-orientation", "vertical"), - h.Attribute("data-rac", ""), h.Id(listboxId(model.id)), h.Role("listbox"), ], @@ -320,7 +317,6 @@ const option = (model: Model, open: Open, candidate: Item, h: HtmlBuilder<Messag ...flag("data-focused", isFocused), ...flag("data-hovered", isHovered), h.Attribute("data-key", key), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ...flag("data-selected", isSelected), h.Attribute("data-selection-mode", "single"), diff --git a/apps/web-foldkit/src/ui/command-menu-view.ts b/apps/web-foldkit/src/ui/command-menu-view.ts index e38abf0ee..c32675106 100644 --- a/apps/web-foldkit/src/ui/command-menu-view.ts +++ b/apps/web-foldkit/src/ui/command-menu-view.ts @@ -154,7 +154,6 @@ const commandMenuOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder [ h.Class(commandMenuOverlayClassName(viewInputs.isBlurred ?? false)), h.Attribute("data-modal-overlay", ""), - h.Attribute("data-rac", ""), ], [ h.div( @@ -166,7 +165,6 @@ const commandMenuOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder ), ), h.Attribute("data-modal-content", model.id), - h.Attribute("data-rac", ""), ], [ ...(isDismissable ? [dismissButton(h, Message.ClickedDismiss())] : []), @@ -174,7 +172,6 @@ const commandMenuOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder [ h.Attribute("aria-label", viewInputs["aria-label"] ?? "Command Menu"), h.Class(commandMenuDialogClassName), - h.Attribute("data-rac", ""), h.Id(dialogId(model.id)), h.Role("dialog"), h.Attribute("tabindex", "-1"), @@ -215,7 +212,6 @@ const searchField = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Messag [ h.Class(twMerge(twMerge(commandMenuSearchBase), undefined)), ...(model.inputValue === "" ? [h.Attribute("data-empty", "true")] : []), - h.Attribute("data-rac", ""), ], [ IconMagnifier3(h, { @@ -273,7 +269,6 @@ const list = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Ht h.Attribute("aria-label", "Suggestions"), h.Class(twMerge(twMerge(commandMenuListBase), undefined)), ...(isEmpty ? [h.Attribute("data-empty", "true")] : []), - h.Attribute("data-rac", ""), h.Id(listId(model.id)), h.Role("menu"), ], @@ -285,7 +280,6 @@ const list = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Ht [ ...(Option.isSome(entry.label) ? [h.Attribute("aria-labelledby", headerId)] : []), h.Class(twMerge(commandMenuSectionBase, undefined)), - h.Attribute("data-rac", ""), h.Role("group"), ], [ @@ -333,7 +327,6 @@ const menuItem = (model: Model, viewInputs: ViewInputs, candidate: Item, h: Html ...flag("data-focus-visible", isFocused && model.modality === "Keyboard"), ...flag("data-focused", isFocused), ...flag("data-hovered", isHovered), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("data-slot", "menu-item"), h.Id(itemId(model.id, key)), diff --git a/apps/web-foldkit/src/ui/date-field.ts b/apps/web-foldkit/src/ui/date-field.ts index 583291ca8..250e7127b 100644 --- a/apps/web-foldkit/src/ui/date-field.ts +++ b/apps/web-foldkit/src/ui/date-field.ts @@ -79,7 +79,6 @@ export const dateSegments = <ParentMessage>( [ h.AriaHidden(true), h.Class(twJoin(...dateSegmentStyles)), - h.DataAttribute("rac", ""), h.DataAttribute("type", "literal"), ...statusAttributes, ], @@ -94,7 +93,6 @@ export const dateSegments = <ParentMessage>( h.Id(id), h.Class(twJoin(...dateSegmentStyles)), h.Role("spinbutton"), - h.DataAttribute("rac", ""), h.DataAttribute("type", type), ...statusAttributes, ...(part.isPlaceholder ? [h.DataAttribute("placeholder", "true")] : []), @@ -197,7 +195,6 @@ export const dateField = <ParentMessage>( h.Id(ids.group), h.Class(twMerge(twMerge(...dateInputStyles), part.className)), h.Role("group"), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.Attribute("style", "unicode-bidi: isolate;"), ...statusAttributes, @@ -226,7 +223,6 @@ export const dateField = <ParentMessage>( h.input([ // React Aria's hidden Input renders an empty class attribute. h.Attribute("class", ""), - h.DataAttribute("rac", ""), h.Hidden(true), h.Attribute("style", ""), h.Type("text"), @@ -246,7 +242,6 @@ export const dateField = <ParentMessage>( h.div( [ h.Class(twMerge(twMerge(fieldStyles({ className: dateFieldClassName })), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), ...statusAttributes, ], diff --git a/apps/web-foldkit/src/ui/date-picker-view.ts b/apps/web-foldkit/src/ui/date-picker-view.ts index dde2cb05c..bee9fa763 100644 --- a/apps/web-foldkit/src/ui/date-picker-view.ts +++ b/apps/web-foldkit/src/ui/date-picker-view.ts @@ -82,7 +82,6 @@ const overlay = (h: HtmlBuilder<Message>, model: Model): Html => [ h.Class(twMerge(twMerge(...popoverContentBase), datePickerOverlayClassName(1))), h.Attribute("data-popover", ""), - h.Attribute("data-rac", ""), h.Attribute("data-trigger", "DatePicker"), h.Attribute("dir", "ltr"), h.Id(popoverId(model.id)), @@ -146,7 +145,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.AriaLabel("Calendar"), h.AriaLabelledBy(`${triggerId(model.id)} ${nameRef}`), h.Class(datePickerTriggerClassName), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.DataAttribute("slot", "date-picker-trigger"), h.Id(triggerId(model.id)), @@ -181,7 +179,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.div( [ h.Class(twMerge(twMerge(...dateInputStyles), undefined)), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.Id(`${model.id}-input`), h.Role("presentation"), @@ -198,7 +195,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view ), h.input([ h.Attribute("class", ""), - h.DataAttribute("rac", ""), h.Hidden(true), h.Attribute("style", ""), h.Type("text"), @@ -216,7 +212,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.Class(twMerge(twMerge(fieldStyles()), viewInputs.className)), ...(model.isOpen ? [h.DataAttribute("open", "true")] : []), ...(rootState.isFocusWithin ? [h.DataAttribute("focus-within", "true")] : []), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), ...Interaction.handlers(h, wiring, rootTarget, { isWithin: true, diff --git a/apps/web-foldkit/src/ui/dialog.ts b/apps/web-foldkit/src/ui/dialog.ts index f3117d951..616e1c662 100644 --- a/apps/web-foldkit/src/ui/dialog.ts +++ b/apps/web-foldkit/src/ui/dialog.ts @@ -38,7 +38,6 @@ export const dialog = <Message>( ...(options.labelledBy ? [h.Attribute("aria-labelledby", options.labelledBy)] : []), ...(options.ariaLabel ? [h.Attribute("aria-label", options.ariaLabel)] : []), h.Class(twMerge(dialogBase, options.className)), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "dialog"), h.Id(options.id), h.Role(options.role), diff --git a/apps/web-foldkit/src/ui/field.ts b/apps/web-foldkit/src/ui/field.ts index 4f2222012..268bd168f 100644 --- a/apps/web-foldkit/src/ui/field.ts +++ b/apps/web-foldkit/src/ui/field.ts @@ -59,7 +59,6 @@ export const fieldError = <Message>( h.span( [ h.Class(twMerge(twMerge(fieldErrorStyles()), options.className)), - h.DataAttribute("rac", ""), h.Attribute("slot", "errorMessage"), ...(options.id === undefined ? [] : [h.Id(options.id)]), ...(options.attributes ?? []), diff --git a/apps/web-foldkit/src/ui/input.ts b/apps/web-foldkit/src/ui/input.ts index 7b8865678..d8c0aa5e7 100644 --- a/apps/web-foldkit/src/ui/input.ts +++ b/apps/web-foldkit/src/ui/input.ts @@ -62,7 +62,6 @@ export const inputElement = <Message>( : [h.Attribute("value", options.defaultValue)] return h.input([ h.Class(twMerge(twMerge(...inputStyles), options.className)), - h.DataAttribute("rac", ""), h.Attribute("style", ""), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), ...(isDisabled ? [h.Disabled(true), h.DataAttribute("disabled", "true")] : []), @@ -114,7 +113,6 @@ export const inputGroup = <Message>( h.div( [ h.Class(twMerge(twMerge(...inputGroupStyles), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), h.Role(options.role ?? "group"), ...(options.isDisabled ? [h.DataAttribute("disabled", "true")] : []), diff --git a/apps/web-foldkit/src/ui/link.ts b/apps/web-foldkit/src/ui/link.ts index 8134ec4ba..ba4f03649 100644 --- a/apps/web-foldkit/src/ui/link.ts +++ b/apps/web-foldkit/src/ui/link.ts @@ -35,7 +35,6 @@ export const link = <Message>( ...(isDisabled ? [h.AriaDisabled(true), h.DataAttribute("disabled", "true")] : []), ...(interaction ? [ - h.DataAttribute("rac", ""), ...Interaction.handlers(h, interaction.wiring, interaction.target, { isHoverDisabled: isDisabled, isPressDisabled: isDisabled, diff --git a/apps/web-foldkit/src/ui/loader.ts b/apps/web-foldkit/src/ui/loader.ts index 6b322ad65..93184ba38 100644 --- a/apps/web-foldkit/src/ui/loader.ts +++ b/apps/web-foldkit/src/ui/loader.ts @@ -101,8 +101,6 @@ export const loader = <Message>( h.AriaLabel(options.ariaLabel ?? "Pending..."), h.AriaValuemax(100), h.AriaValuemin(0), - h.Class("react-aria-ProgressBar"), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "loader"), h.Role("progressbar"), ], diff --git a/apps/web-foldkit/src/ui/menu-view.ts b/apps/web-foldkit/src/ui/menu-view.ts index 0edb3e1cd..3f6c39dbf 100644 --- a/apps/web-foldkit/src/ui/menu-view.ts +++ b/apps/web-foldkit/src/ui/menu-view.ts @@ -317,7 +317,6 @@ const overlay = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde [ ...(isAtPointer ? [] : [h.Attribute("aria-labelledby", triggerId(model.id))]), h.Class(popoverClassName(viewInputs.popoverClassName)), - h.Attribute("data-rac", ""), ...(isAtPointer ? [] : [h.Attribute("data-trigger", "MenuTrigger")]), h.Attribute("data-menu-popover", model.id), h.Attribute("data-popover", ""), @@ -389,7 +388,6 @@ const submenuPopover = ( [ h.Attribute("aria-labelledby", itemId(model.id, triggerKey)), h.Class(popoverClassName(undefined)), - h.Attribute("data-rac", ""), h.Attribute("data-trigger", "SubmenuTrigger"), h.Attribute("data-menu-popover", model.id), h.Attribute("dir", "ltr"), @@ -444,7 +442,6 @@ const menuElement = ( [ ...(level.labelledBy ? [h.Attribute("aria-labelledby", level.labelledBy)] : []), h.Class(menuContentStyles({ className: level.className })), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "menu-content"), h.Id(level.elementId), h.Role("menu"), @@ -480,7 +477,6 @@ const menuElement = ( onSome: (id) => [h.Attribute("aria-labelledby", id)], }), h.Class(section()), - h.Attribute("data-rac", ""), h.Role("group"), ], [ @@ -578,7 +574,6 @@ const menuItem = ( ...flag("data-has-submenu", hasSubmenu), ...flag("data-hovered", isHovered), ...flag("data-open", isSubmenuOpen), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ...flag("data-selected", isSelected), ...(isSelectable ? [h.Attribute("data-selection-mode", "single")] : []), diff --git a/apps/web-foldkit/src/ui/modal.ts b/apps/web-foldkit/src/ui/modal.ts index a96c03b9c..628222162 100644 --- a/apps/web-foldkit/src/ui/modal.ts +++ b/apps/web-foldkit/src/ui/modal.ts @@ -195,7 +195,6 @@ const modalOverlay = <ParentMessage>( [ h.Class(modalOverlayClassName(size, viewInputs.isBlurred ?? false)), h.Attribute("data-modal-overlay", ""), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "modal-overlay"), ], [ @@ -203,7 +202,6 @@ const modalOverlay = <ParentMessage>( [ h.Class(twMerge(twMerge(...modalContentBase(size)), viewInputs.className)), h.Attribute("data-modal-content", model.id), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "modal-content"), ], [ diff --git a/apps/web-foldkit/src/ui/popover.ts b/apps/web-foldkit/src/ui/popover.ts index 18988ea1a..fd5674293 100644 --- a/apps/web-foldkit/src/ui/popover.ts +++ b/apps/web-foldkit/src/ui/popover.ts @@ -153,7 +153,6 @@ const popoverOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Mes h.Attribute("aria-labelledby", triggerId(model.id)), h.Class(twMerge(twMerge(...popoverContentBase), viewInputs.className)), h.Attribute("data-popover", ""), - h.Attribute("data-rac", ""), h.Attribute("data-trigger", "DialogTrigger"), h.Attribute("dir", "ltr"), h.Id(popoverId(model.id)), @@ -168,7 +167,7 @@ const popoverOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Mes ...(arrow ? [ h.div( - [h.Class("group"), h.Attribute("data-rac", "")], + [h.Class("group")], [ h.svg( [ diff --git a/apps/web-foldkit/src/ui/progress-bar.ts b/apps/web-foldkit/src/ui/progress-bar.ts index 55ae902e3..ca1ff3d68 100644 --- a/apps/web-foldkit/src/ui/progress-bar.ts +++ b/apps/web-foldkit/src/ui/progress-bar.ts @@ -29,7 +29,6 @@ export const progressBar = <Message>( h.AriaValuemax(100), h.AriaValuemin(0), h.Class(twMerge(twMerge(...progressBarStyles.root), options.className)), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "control"), h.Role("progressbar"), ], diff --git a/apps/web-foldkit/src/ui/radio.scene.test.ts b/apps/web-foldkit/src/ui/radio.scene.test.ts index 70f554a14..285b5115c 100644 --- a/apps/web-foldkit/src/ui/radio.scene.test.ts +++ b/apps/web-foldkit/src/ui/radio.scene.test.ts @@ -20,7 +20,6 @@ const styledView = onChange: (value) => Message.ChoseValue({ value }), isDisabled, isInvalid, - interaction: wiring(model), }, (parts) => [ parts.label(["Plan"]), @@ -110,11 +109,11 @@ const unstyledView = ]) describe("unstyled aria radio group scene", () => { - test("an aria-labelled group uses the React Aria default classes", () => { + test("an aria-labelled group is a named radiogroup", () => { Scene.scene( { update, view: unstyledView() }, Scene.given(init()), - Scene.expect(Scene.role("radiogroup", { name: "Size" })).toHaveClass("react-aria-RadioGroup"), + Scene.expect(Scene.role("radiogroup", { name: "Size" })).toExist(), Scene.expect(Scene.role("radio", { name: "Small" })).not.toBeChecked(), ) }) diff --git a/apps/web-foldkit/src/ui/radio.ts b/apps/web-foldkit/src/ui/radio.ts index 10ea8918a..167966493 100644 --- a/apps/web-foldkit/src/ui/radio.ts +++ b/apps/web-foldkit/src/ui/radio.ts @@ -7,14 +7,13 @@ import { radioStyles, } from "~/components/ui/radio.styles" import * as Collection from "./aria/collection" -import * as Interaction from "./aria/interaction" import { visuallyHiddenStyle } from "./checkbox" import * as Field from "./field" /** - * Port of `components/ui/radio.tsx` (React Aria RadioGroup and Radio). Radios share the group id as - * their `name`, so the browser's arrow-key navigation moves selection and focus like useRadioGroup. - * Pressing a label prevents the native click, selects, and focuses the input (useRadio). + * A radio group of native radios. Radios share the group id as their `name`, so the browser's + * arrow keys move selection and focus. Pressing a label prevents the native click, selects, and + * focuses the input. Hover and the keyboard focus ring are native CSS on the label. */ export interface RadioGroupOptions<Message> { readonly id: string @@ -23,7 +22,6 @@ export interface RadioGroupOptions<Message> { readonly isDisabled?: boolean readonly isInvalid?: boolean readonly className?: string - readonly interaction?: Interaction.Wiring<Message> } export interface RadioGroupParts<Message> { @@ -49,7 +47,6 @@ export const radioGroup = <Message>( } const isDisabled = options.isDisabled ?? false const isInvalid = options.isInvalid ?? false - const interaction = options.interaction const used = new Set<"label" | "description">() const describedBy = (hasDescription: boolean) => [...(hasDescription ? [ids.description] : []), ...(isInvalid ? [ids.error] : [])].join(" ") || @@ -60,7 +57,6 @@ export const radioGroup = <Message>( (value: string, children: string | Array<Html>, part: { readonly className?: string } = {}) => { const id = `${options.id}-${value}` const isSelected = options.value === value - const state = interaction ? Interaction.stateOf(interaction.model, id) : Interaction.idleState const select = options.onChange?.(value) const inputDescribedBy = describedBy(hasDescription) // Roving tabindex: the selected radio, or every radio while nothing is selected. @@ -72,22 +68,10 @@ export const radioGroup = <Message>( return h.label( [ h.Class(twMerge(twMerge(radioStyles), part.className)), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), ...(isSelected ? [h.DataAttribute("selected", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), ...(isInvalid ? [h.DataAttribute("invalid", "true")] : []), - ...(interaction - ? [ - ...Interaction.handlers(h, interaction, id, { - isHoverDisabled: isDisabled, - isPressDisabled: isDisabled, - isFocusDisabled: true, - }), - ...Interaction.stateAttributes(h, state), - ...Interaction.pressStyleAttributes(h, interaction.model, id), - ] - : []), ...(select === undefined || isDisabled ? [] : [ @@ -115,13 +99,6 @@ export const radioGroup = <Message>( ? [] : [h.AriaDescribedBy(inputDescribedBy)]), h.Checked(isSelected), - ...(interaction - ? Interaction.handlers(h, interaction, id, { - isHoverDisabled: true, - isPressDisabled: true, - isFocusDisabled: isDisabled, - }) - : []), // Arrow keys check the next radio natively; the Model follows. ...(select === undefined ? [] : [h.OnChange(() => select)]), ]), @@ -136,7 +113,6 @@ export const radioGroup = <Message>( twMerge( radioIndicatorStyles({ isSelected, - isFocusVisible: state.isFocusVisible, isInvalid, }), ), @@ -169,7 +145,6 @@ export const radioGroup = <Message>( return h.div( [ h.Class(twMerge(twMerge(...radioGroupStyles), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), h.DataAttribute("orientation", "vertical"), h.Role("radiogroup"), diff --git a/apps/web-foldkit/src/ui/search-field.ts b/apps/web-foldkit/src/ui/search-field.ts index 1266a0562..086df945e 100644 --- a/apps/web-foldkit/src/ui/search-field.ts +++ b/apps/web-foldkit/src/ui/search-field.ts @@ -141,7 +141,6 @@ export const searchField = <Message>( return h.div( [ h.Class(twMerge(twMerge(fieldStyles({ className: searchFieldStyles.field })), options.className)), - h.DataAttribute("rac", ""), ...(isEmpty ? [h.DataAttribute("empty", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), ...(isInvalid ? [h.DataAttribute("invalid", "true")] : []), diff --git a/apps/web-foldkit/src/ui/select-view.ts b/apps/web-foldkit/src/ui/select-view.ts index 65a7573b7..133423ada 100644 --- a/apps/web-foldkit/src/ui/select-view.ts +++ b/apps/web-foldkit/src/ui/select-view.ts @@ -144,7 +144,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view ...flag("data-disabled", model.isDisabled), ...flag("data-focused", isOpen || model.isTriggerFocused), ...flag("data-open", isOpen), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "control"), ...(viewInputs.style === undefined ? [] : [h.Attribute("style", viewInputs.style)]), ], @@ -218,7 +217,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view [ h.Class(selectValueClassName), h.Attribute("data-placeholder", "true"), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "select-value"), h.Id(valueId(model.id)), ], @@ -228,7 +226,6 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.span( [ h.Class(selectValueClassName), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "select-value"), h.Id(valueId(model.id)), ], @@ -332,7 +329,6 @@ const selectPopover = (model: Model, open: Open, viewInputs: ViewInputs, h: Html ), ), h.Attribute("data-popover", ""), - h.Attribute("data-rac", ""), h.Attribute("data-select-popover", model.id), h.Attribute("data-trigger", "Select"), h.Attribute("dir", "ltr"), @@ -383,7 +379,6 @@ const listbox = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde h.Class(twMerge(twMerge(selectListBoxBase), viewInputs.listBoxClassName)), h.Attribute("data-layout", "stack"), h.Attribute("data-orientation", "vertical"), - h.Attribute("data-rac", ""), h.Id(listboxId(model.id)), h.Role("listbox"), h.Attribute("tabindex", Option.isSome(open.focusedKey) ? "-1" : "0"), @@ -417,7 +412,6 @@ const option = ( ...flag("data-focused", isFocused), ...flag("data-hovered", isHovered), h.Attribute("data-key", key), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), ...flag("data-selected", isSelected), h.Attribute("data-selection-mode", "single"), diff --git a/apps/web-foldkit/src/ui/sheet.ts b/apps/web-foldkit/src/ui/sheet.ts index 2467c3cea..0a9b023dd 100644 --- a/apps/web-foldkit/src/ui/sheet.ts +++ b/apps/web-foldkit/src/ui/sheet.ts @@ -79,7 +79,6 @@ const sheetOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Messa [ h.Class(sheetOverlayClassName(false, false, viewInputs.isBlurred ?? false)), h.Attribute("data-modal-overlay", ""), - h.Attribute("data-rac", ""), ...Object.entries(viewInputs.overlayAttributes ?? {}).map(([name, value]) => h.Attribute(name, value), ), @@ -97,7 +96,6 @@ const sheetOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Messa }), ), h.Attribute("data-modal-content", model.id), - h.Attribute("data-rac", ""), ], [ ...(isDismissable ? [dismissButton(h, Message.ClickedClose())] : []), diff --git a/apps/web-foldkit/src/ui/sidebar.ts b/apps/web-foldkit/src/ui/sidebar.ts index 0c039fdeb..958c6da86 100644 --- a/apps/web-foldkit/src/ui/sidebar.ts +++ b/apps/web-foldkit/src/ui/sidebar.ts @@ -245,7 +245,7 @@ export const sidebarItem = <Message>( "cursor-default", "w-full min-w-0 items-center rounded-lg text-left font-medium text-base/6 text-sidebar-fg", "group/sidebar-item relative col-span-full overflow-hidden focus-visible:outline-hidden", - "**:data-[slot=menu-trigger]:absolute **:data-[slot=menu-trigger]:right-0 **:data-[slot=menu-trigger]:flex **:data-[slot=menu-trigger]:h-full **:data-[slot=menu-trigger]:w-auto **:data-[slot=menu-trigger]:items-center **:data-[slot=menu-trigger]:justify-end **:data-[slot=menu-trigger]:px-2.5 **:data-[slot=menu-trigger]:opacity-0 **:data-[slot=menu-trigger]:pressed:opacity-100 **:data-[slot=menu-trigger]:has-data-focus:opacity-100 **:data-[slot=menu-trigger]:focus-visible:opacity-100 hover:**:data-[slot=menu-trigger]:opacity-100", + "**:data-[slot=menu-trigger]:absolute **:data-[slot=menu-trigger]:right-0 **:data-[slot=menu-trigger]:flex **:data-[slot=menu-trigger]:h-full **:data-[slot=menu-trigger]:w-auto **:data-[slot=menu-trigger]:items-center **:data-[slot=menu-trigger]:justify-end **:data-[slot=menu-trigger]:px-2.5 **:data-[slot=menu-trigger]:opacity-0 **:data-[slot=menu-trigger]:data-pressed:opacity-100 **:data-[slot=menu-trigger]:has-data-focus:opacity-100 **:data-[slot=menu-trigger]:focus-visible:opacity-100 hover:**:data-[slot=menu-trigger]:opacity-100", "**:data-[slot=icon]:size-5 **:data-[slot=icon]:shrink-0 **:data-[slot=icon]:text-muted-fg sm:**:data-[slot=icon]:size-4", "**:last:data-[slot=icon]:size-5 sm:**:last:data-[slot=icon]:size-4", "has-[[data-slot=avatar]]:has-[[data-slot=sidebar-label]]:gap-2 has-[[data-slot=icon]]:has-[[data-slot=sidebar-label]]:gap-2", diff --git a/apps/web-foldkit/src/ui/slider.ts b/apps/web-foldkit/src/ui/slider.ts index 3902dd54a..e3ea7524a 100644 --- a/apps/web-foldkit/src/ui/slider.ts +++ b/apps/web-foldkit/src/ui/slider.ts @@ -279,7 +279,6 @@ export const slider = <ParentMessage>( return h.div( [ h.Class(twMerge(twMerge(sliderThumbStyles))), - h.DataAttribute("rac", ""), h.Attribute( "style", `position: absolute; ${position} transform: translate(-50%, -50%); touch-action: none;`, @@ -351,7 +350,6 @@ export const slider = <ParentMessage>( [ h.Class(twMerge(twMerge(sliderOutputStyles), outputOptions.className)), h.AriaLive("off"), - h.DataAttribute("rac", ""), orientationAttribute, ...disabledAttributes, h.For(model.values.map((_, index) => thumbId(index)).join(" ")), @@ -366,7 +364,6 @@ export const slider = <ParentMessage>( [ h.Id(ids.track), h.Class(twMerge(twMerge(...sliderTrackStyles))), - h.DataAttribute("rac", ""), orientationAttribute, ...disabledAttributes, h.Attribute("style", "position: relative; touch-action: none;"), @@ -435,7 +432,6 @@ export const slider = <ParentMessage>( return h.div( [ h.Class(twMerge(twMerge(...sliderStyles), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), orientationAttribute, h.Role("group"), diff --git a/apps/web-foldkit/src/ui/switch.scene.test.ts b/apps/web-foldkit/src/ui/switch.scene.test.ts index 08e7f7b09..7b00c4cb1 100644 --- a/apps/web-foldkit/src/ui/switch.scene.test.ts +++ b/apps/web-foldkit/src/ui/switch.scene.test.ts @@ -2,7 +2,7 @@ import * as Scene from "foldkit/scene" import type { HtmlBuilder } from "foldkit/html" import { describe, test } from "vitest" -import { init, Message, type Model, page, update, wiring } from "../test/kit-forms-fixtures" +import { init, Message, type Model, page, update } from "../test/kit-forms-fixtures" import { switchControl, type SwitchOptions } from "./switch" /** Switch: a native checkbox with role switch, toggled by its label like a Checkbox. */ @@ -17,7 +17,6 @@ const view = id: "wifi", isSelected: model.isSelected, onChange: (isSelected) => Message.ChangedSelection({ isSelected }), - interaction: wiring(model), ...options, }, "Wi-Fi", @@ -51,38 +50,33 @@ describe("switch scene", () => { ) }) - test("Space on the focused switch flips it through the native change", () => { + test("Space on the switch flips it through the native change", () => { Scene.scene( { update, view: view() }, Scene.given(init()), - Scene.focus(toggleSwitch), Scene.change(toggleSwitch, "on"), Scene.expect(toggleSwitch).toBeChecked(), - Scene.expect(control).toHaveAttr("data-focus-visible", "true"), ) }) - test("a pointer press marks the switch pressed and blocks text selection", () => { + test("the keyboard focus ring and hover are native CSS on the label, with no wiring", () => { + const track = Scene.selector(`[data-slot="indicator"]`) Scene.scene( { update, view: view() }, Scene.given(init()), - Scene.pointerDown(control), - Scene.expect(control).toHaveAttr("data-pressed", "true"), - Scene.expect(control).toHaveAttr( - "style", - "-webkit-tap-highlight-color: transparent; user-select: none;", - ), + Scene.expect(control).toHaveClass("group"), + Scene.expect(track).toHaveClass("group-has-focus-visible:ring-2"), + Scene.expect(track).toHaveClass("group-hover:inset-ring-muted-fg/30"), ) }) - test("a disabled switch is disabled with no click or hover handlers", () => { + test("a disabled switch is disabled with no click handler", () => { Scene.scene( { update, view: view({ isDisabled: true }) }, Scene.given(init()), Scene.expect(toggleSwitch).toBeDisabled(), Scene.expect(control).toHaveAttr("data-disabled", "true"), Scene.expect(control).not.toHaveHandler("click"), - Scene.expect(control).not.toHaveHandler("mouseenter"), ) }) }) diff --git a/apps/web-foldkit/src/ui/switch.ts b/apps/web-foldkit/src/ui/switch.ts index 5aadf68b0..375c5ab8e 100644 --- a/apps/web-foldkit/src/ui/switch.ts +++ b/apps/web-foldkit/src/ui/switch.ts @@ -2,19 +2,20 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { twJoin, twMerge } from "tailwind-merge" import { switchIndicatorStyles, switchStyles, switchThumbStyles } from "~/components/ui/switch.styles" import * as Collection from "./aria/collection" -import * as Interaction from "./aria/interaction" import { visuallyHiddenStyle } from "./checkbox" import * as Field from "./field" -/** Port of `components/ui/switch.tsx` (React Aria Switch, which presses like a Checkbox label). */ +/** + * A switch: a `<label>` around a visually hidden `role="switch"` checkbox, toggled by its label like + * a Checkbox. Hover and the keyboard focus ring are native CSS (`group-hover`, `group-has-focus-visible`). + */ export interface SwitchOptions<Message> { - /** Element id of the input and the interaction target. */ + /** Element id of the input. */ readonly id: string readonly isSelected: boolean readonly isDisabled?: boolean readonly onChange?: (isSelected: boolean) => Message readonly className?: string - readonly interaction?: Interaction.Wiring<Message> } export const switchControl = <Message>( @@ -23,41 +24,19 @@ export const switchControl = <Message>( children: string | Array<Html>, ): Html => { const isDisabled = options.isDisabled ?? false - const interaction = options.interaction - const state = interaction ? Interaction.stateOf(interaction.model, options.id) : Interaction.idleState const toggle = options.onChange?.(!options.isSelected) - const values = { - isHovered: state.isHovered, - isFocusVisible: state.isFocusVisible, - isSelected: options.isSelected, - isDisabled, - } - // The style prop, then usePress's userSelect while a pointer press is held. - const isPointerPressing = - interaction?.model.press?.target === options.id && interaction.model.press.source === "pointer" - const style = `-webkit-tap-highlight-color: transparent;${isPointerPressing ? " user-select: none;" : ""}` + const values = { isSelected: options.isSelected, isDisabled } const content = typeof children === "string" ? [Field.label(h, { elementType: "span" }, [children])] : children return h.label( [ h.Class(twMerge(twMerge(switchStyles), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("react-aria-pressable", "true"), h.DataAttribute("slot", "control"), - h.Attribute("style", style), + h.Attribute("style", "-webkit-tap-highlight-color: transparent;"), ...(options.isSelected ? [h.DataAttribute("selected", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), - ...(interaction - ? [ - ...Interaction.handlers(h, interaction, options.id, { - isHoverDisabled: isDisabled, - isPressDisabled: isDisabled, - isFocusDisabled: true, - }), - ...Interaction.stateAttributes(h, state), - ] - : []), ...(toggle === undefined || isDisabled ? [] : [ @@ -75,17 +54,8 @@ export const switchControl = <Message>( h.Id(options.id), h.Type("checkbox"), h.Role("switch"), - h.DataAttribute("react-aria-pressable", "true"), - h.Attribute("style", ""), ...(isDisabled ? [h.Disabled(true)] : [h.Tabindex(0)]), h.Checked(options.isSelected), - ...(interaction - ? Interaction.handlers(h, interaction, options.id, { - isHoverDisabled: true, - isPressDisabled: true, - isFocusDisabled: isDisabled, - }) - : []), ...(toggle === undefined ? [] : [h.OnChange(() => toggle)]), ]), ], diff --git a/apps/web-foldkit/src/ui/tab-strip.ts b/apps/web-foldkit/src/ui/tab-strip.ts index 17aecb835..09544cd8f 100644 --- a/apps/web-foldkit/src/ui/tab-strip.ts +++ b/apps/web-foldkit/src/ui/tab-strip.ts @@ -1,10 +1,10 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" -import { cx } from "~/utils/cx" +import { tabsStyles } from "~/components/ui/tabs.styles" /** - * Stateless `Tabs` > `TabList` > `Tab` (`components/ui/tabs.tsx`, horizontal) for tab bars whose - * selection is the route: the chat tab bar and the channel settings tabs. `ui/tabs.ts` is the stateful kit. + * A horizontal tab list whose selection is the route (the chat tab bar and the channel settings + * tabs). Hover and focus rings are native CSS; `ui/tabs.ts` is the stateful kit. */ export const tabView = <M>( @@ -19,29 +19,13 @@ export const tabView = <M>( ): Html => h.div( [ - ...(options.isSelected - ? [h.Attribute("aria-selected", "true"), h.Attribute("data-selected", "true")] - : [h.Attribute("aria-selected", "false")]), - h.Class( - twMerge( - twMerge( - "group/tab rounded-lg [--tab-gutter:var(--tab-gutter-x)]", - "[--tab-gutter-x:--spacing(2.5)] [--tab-gutter-y:--spacing(1)] first:-ml-(--tab-gutter) last:-mr-(--tab-gutter)", - "relative isolate flex cursor-default items-center whitespace-nowrap font-medium text-sm/6 outline-hidden transition", - "px-(--tab-gutter-x) py-(--tab-gutter-y)", - "*:data-[slot=icon]:mr-2 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-muted-fg selected:*:data-[slot=icon]:text-primary-subtle-fg", - "selected:text-primary-subtle-fg text-muted-fg hover:bg-secondary selected:hover:bg-primary-subtle hover:text-fg selected:hover:text-primary-subtle-fg focus:ring-0", - "disabled:opacity-50", - "cursor-default", - ), - ), - ), - h.Attribute("data-key", options.id), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("data-slot", "tab"), - h.Attribute("role", "tab"), - h.Attribute("tabindex", options.isSelected ? "0" : "-1"), + h.AriaSelected(options.isSelected), + ...(options.isSelected ? [h.DataAttribute("selected", "true")] : []), + h.Class(twMerge(tabsStyles.tab("horizontal", false))), + h.DataAttribute("key", options.id), + h.DataAttribute("slot", "tab"), + h.Role("tab"), + h.Tabindex(options.isSelected ? 0 : -1), ...(options.attributes ?? []), ], [ @@ -50,14 +34,8 @@ export const tabView = <M>( ...(options.isSelected ? [ h.div([ - h.Class( - twMerge( - "absolute bg-primary-subtle-fg transition-[translate,width,height] duration-200", - "right-(--tab-gutter-x) -bottom-[calc(var(--tab-gutter-y)+1px)] left-(--tab-gutter-x) h-[2px]", - ), - ), - h.Attribute("data-rac", ""), - h.Attribute("data-slot", "selected-indicator"), + h.Class(twMerge(tabsStyles.selectionIndicator("horizontal"))), + h.DataAttribute("slot", "selected-indicator"), ]), ] : []), @@ -72,26 +50,17 @@ export const tabStrip = <M>( ): Html => h.div( [ - h.Class(cx("flex-col", "group/tabs flex gap-4 forced-color-adjust-none", options.className)), - h.Attribute("data-orientation", "horizontal"), - h.Attribute("data-rac", ""), + h.Class(twMerge(tabsStyles.tabs("horizontal"), options.className)), + h.DataAttribute("orientation", "horizontal"), ], [ h.div( [ - h.Attribute("aria-orientation", "horizontal"), - h.Class( - twMerge([ - "[--tab-list-gutter:--spacing(1)]", - "relative flex forced-color-adjust-none", - "flex-row gap-x-(--tab-list-gutter) rounded-(--tab-list-rounded) border-b py-(--tab-list-gutter)", - options.tabListClassName, - ]), - ), - h.Attribute("data-orientation", "horizontal"), - h.Attribute("data-rac", ""), - h.Attribute("data-slot", "tab-list"), - h.Attribute("role", "tablist"), + h.AriaOrientation("horizontal"), + h.Class(twMerge(tabsStyles.tabList("horizontal"), options.tabListClassName)), + h.DataAttribute("orientation", "horizontal"), + h.DataAttribute("slot", "tab-list"), + h.Role("tablist"), ], [...tabs], ), diff --git a/apps/web-foldkit/src/ui/tabs.ts b/apps/web-foldkit/src/ui/tabs.ts index b981988cc..dfaac257c 100644 --- a/apps/web-foldkit/src/ui/tabs.ts +++ b/apps/web-foldkit/src/ui/tabs.ts @@ -199,7 +199,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h ...(isSelected ? [h.AriaControls(panelId(model, tab.key))] : []), h.Attribute("data-collection", model.id), h.Attribute("data-key", tab.key), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "tab"), ...Collection.stateAttributes(h, { ...state, isSelected, isDisabled: isTabDisabled }), ...interactive, @@ -210,7 +209,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h ? [ h.div([ h.Class(twMerge(tabsStyles.selectionIndicator(orientation))), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "selected-indicator"), ]), ] @@ -228,7 +226,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h h.Role("tabpanel"), h.AriaLabelledBy(tabId(model, panel.key)), h.Tabindex(0), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "tab-panel"), ...Collection.stateAttributes(h, { isFocused: isPanelFocused, @@ -245,7 +242,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h [ h.Class(twMerge(twMerge(...tabsStyles.tabs(orientation)), viewInputs.className)), h.Attribute("data-orientation", orientation), - h.Attribute("data-rac", ""), ...Collection.stateAttributes(h, { isFocused: isRootFocused, isFocusVisible: isRootFocused && isFocusVisible, @@ -261,7 +257,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h h.AriaOrientation(orientation), h.Attribute("data-collection", model.id), h.Attribute("data-orientation", orientation), - h.Attribute("data-rac", ""), h.Attribute("data-slot", "tab-list"), ], Array.map(viewInputs.tabs, tabView), diff --git a/apps/web-foldkit/src/ui/text-field.ts b/apps/web-foldkit/src/ui/text-field.ts index a977c7ce5..f77082288 100644 --- a/apps/web-foldkit/src/ui/text-field.ts +++ b/apps/web-foldkit/src/ui/text-field.ts @@ -84,7 +84,6 @@ export const textField = <Message>( return h.div( [ h.Class(twMerge(twMerge(fieldStyles()), options.className)), - h.DataAttribute("rac", ""), h.DataAttribute("slot", "control"), ...(isInvalid ? [h.DataAttribute("invalid", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] : []), diff --git a/apps/web-foldkit/src/ui/textarea.ts b/apps/web-foldkit/src/ui/textarea.ts index 0835371c3..bfd180713 100644 --- a/apps/web-foldkit/src/ui/textarea.ts +++ b/apps/web-foldkit/src/ui/textarea.ts @@ -38,7 +38,6 @@ export const textarea = <Message>( [ h.textarea([ h.Class(twMerge(twMerge(twJoin(textareaStyles)), options.className)), - h.DataAttribute("rac", ""), h.Attribute("style", ""), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), ...(isDisabled ? [h.Disabled(true), h.DataAttribute("disabled", "true")] : []), diff --git a/apps/web-foldkit/src/ui/toolbar.ts b/apps/web-foldkit/src/ui/toolbar.ts index 8fcc40edf..4bfb3c412 100644 --- a/apps/web-foldkit/src/ui/toolbar.ts +++ b/apps/web-foldkit/src/ui/toolbar.ts @@ -96,7 +96,6 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h h.AriaLabel(model.label), h.AriaOrientation(model.orientation), h.Attribute("data-orientation", model.orientation), - h.Attribute("data-rac", ""), h.OnKeyDownPreventDefault(navigate), ], [...(typeof viewInputs.content === "function" ? viewInputs.content() : viewInputs.content)], diff --git a/apps/web-foldkit/src/ui/tooltip.ts b/apps/web-foldkit/src/ui/tooltip.ts index bf59972b2..24c61e356 100644 --- a/apps/web-foldkit/src/ui/tooltip.ts +++ b/apps/web-foldkit/src/ui/tooltip.ts @@ -293,7 +293,6 @@ const tooltipOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Mes className: viewInputs.className, }), ), - h.Attribute("data-rac", ""), h.Id(tooltipId(model.id)), h.Role("tooltip"), ], @@ -301,7 +300,7 @@ const tooltipOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Mes ...(arrow ? [ h.div( - [h.Class("group"), h.Attribute("data-rac", "")], + [h.Class("group")], [ h.svg( [ diff --git a/bun.lock b/bun.lock index b880b7b37..2086bbaeb 100644 --- a/bun.lock +++ b/bun.lock @@ -288,7 +288,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", @@ -3177,8 +3176,6 @@ "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], - "tailwindcss-react-aria-components": ["tailwindcss-react-aria-components@2.0.1", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-yTAfYv9BE/gKczS+b8UiFMqxnrEYKKNE6Y4vAWzGadkHGb4Yuawp0SHbZKkZJQgFvK0KjO3JpCq/0kzR5jJ9tw=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], From 5759d601ea5d94698f67c6be972bf8ac351c2da2 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 15:59:41 +0200 Subject: [PATCH 10/52] refactor(web): typed errors at the core's Promise boundaries, drop dead helpers - session-bearer: sessionAuthorizationEffect wraps the desktop provider in tryPromise with a SessionBearerError; the RPC auth middleware maps it to SessionLoadError instead of dying through Effect.promise. - access-token: the exchange is an Effect: Schema-decoded token body, a TokenExchangeError (Unreachable | ServerError | BadResponse) and Effect.retry with the same 500ms/1s backoff. The Promise exports stay for the fetch wrappers. A malformed body now counts as unanswered. - image-crop: cropImage and loadImage are Effects failing with CropError. - Command palette: the recent-channels write uses Effect.try + ignore, so a full localStorage is a typed, ignored failure rather than a defect. - Delete Rivet's unused actor helpers (one threw), the date-fns re-export shim and bot-avatar's dead Brandfetch tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/chat/markdown/leaves.test.ts | 8 +- apps/web-foldkit/src/chat/markdown/leaves.ts | 3 +- apps/web-foldkit/src/chat/message/parts.ts | 2 +- apps/web-foldkit/src/composer/draft.ts | 2 +- .../src/core/components/link-preview.utils.ts | 2 +- apps/web-foldkit/src/core/db/collections.ts | 39 ++++++-- .../src/core/lib/access-token.test.ts | 13 +++ apps/web-foldkit/src/core/lib/access-token.ts | 95 +++++++++++-------- apps/web-foldkit/src/core/lib/bot-avatar.ts | 57 +---------- .../src/core/lib/date-fns-shared.ts | 2 - .../src/core/lib/error-messages.ts | 7 +- .../platform-storage/tauri-key-value-store.ts | 5 +- apps/web-foldkit/src/core/lib/rivet-client.ts | 35 +------ .../src/core/lib/rpc-auth-middleware.ts | 15 ++- .../src/core/lib/session-bearer.ts | 25 ++++- apps/web-foldkit/src/core/utils/image-crop.ts | 82 +++++++++------- apps/web-foldkit/src/data/actions.test.ts | 24 +++-- apps/web-foldkit/src/editor/decorations.ts | 12 ++- .../src/notification-sound.test.ts | 4 +- apps/web-foldkit/src/notification-sound.ts | 14 ++- .../src/overlay/command-palette/commands.ts | 5 +- apps/web-foldkit/src/page/chat/pinned.ts | 2 +- .../web-foldkit/src/page/chat/thread-panel.ts | 2 +- .../src/page/my-settings/profile/command.ts | 12 +-- .../src/collection.ts | 2 +- .../src/optimistic-action.ts | 26 ++++- 26 files changed, 275 insertions(+), 220 deletions(-) delete mode 100644 apps/web-foldkit/src/core/lib/date-fns-shared.ts diff --git a/apps/web-foldkit/src/chat/markdown/leaves.test.ts b/apps/web-foldkit/src/chat/markdown/leaves.test.ts index 44062634e..2cb140c91 100644 --- a/apps/web-foldkit/src/chat/markdown/leaves.test.ts +++ b/apps/web-foldkit/src/chat/markdown/leaves.test.ts @@ -17,7 +17,13 @@ describe("text leaves", () => { 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" }, + { + text: "[docs](https://hazel.sh)", + type: "link", + isMarker: false, + url: "https://hazel.sh", + linkText: "docs", + }, ]) }) diff --git a/apps/web-foldkit/src/chat/markdown/leaves.ts b/apps/web-foldkit/src/chat/markdown/leaves.ts index 3f8e61b85..03314796d 100644 --- a/apps/web-foldkit/src/chat/markdown/leaves.ts +++ b/apps/web-foldkit/src/chat/markdown/leaves.ts @@ -52,7 +52,8 @@ const externalLink = <M>(h: HtmlBuilder<M>, url: string, content: Html | string) /** `MarkdownLeaf` in viewer mode. */ const leafView = <M>(h: HtmlBuilder<M>, 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.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.type === "link" && leaf.url !== undefined && leaf.linkText !== undefined) 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/composer/draft.ts b/apps/web-foldkit/src/composer/draft.ts index f4bd961a0..58c72a31b 100644 --- a/apps/web-foldkit/src/composer/draft.ts +++ b/apps/web-foldkit/src/composer/draft.ts @@ -7,7 +7,7 @@ import { OrganizationId, UserId, } from "@hazel/schema" -import { Cause, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command, File } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { editMessageAction, sendMessageAction } from "~/db/actions" 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/db/collections.ts b/apps/web-foldkit/src/core/db/collections.ts index b4b5483df..fb6da0843 100644 --- a/apps/web-foldkit/src/core/db/collections.ts +++ b/apps/web-foldkit/src/core/db/collections.ts @@ -55,7 +55,9 @@ const electricCollection = <A extends { readonly id: string }>( params: { table }, // The parser's value type follows the row, which a generic row can't name; `Schema.JsonDate` // columns accept the string form too, so this is only the fast path. - ...(parseTimestamps ? { parser: { timestamptz: ((date: string) => new Date(date)) as never } } : {}), + ...(parseTimestamps + ? { parser: { timestamptz: ((date: string) => new Date(date)) as never } } + : {}), fetchClient: electricFetchClient, onError: electricOnError, }, @@ -73,14 +75,23 @@ export const notificationCollection = electricCollection("notifications", Notifi syncMode: "on-demand", }) export const userCollection = electricCollection("users", User.Schema) -export const organizationMemberCollection = electricCollection("organization_members", OrganizationMember.Schema) +export const organizationMemberCollection = electricCollection( + "organization_members", + OrganizationMember.Schema, +) export const channelCollection = electricCollection("channels", Channel.Schema) -export const connectConversationCollection = electricCollection("connect_conversations", ConnectConversation.Schema) +export const connectConversationCollection = electricCollection( + "connect_conversations", + ConnectConversation.Schema, +) export const connectConversationChannelCollection = electricCollection( "connect_conversation_channels", ConnectConversationChannel.Schema, ) -export const connectParticipantCollection = electricCollection("connect_participants", ConnectParticipant.Schema) +export const connectParticipantCollection = electricCollection( + "connect_participants", + ConnectParticipant.Schema, +) export const channelMemberCollection = electricCollection("channel_members", ChannelMember.Schema) export const channelSectionCollection = electricCollection("channel_sections", ChannelSection.Schema) export const attachmentCollection = electricCollection("attachments", Attachment.Schema) @@ -88,14 +99,26 @@ export const typingIndicatorCollection = electricCollection("typing_indicators", syncMode: "eager", parseTimestamps: false, }) -export const userPresenceStatusCollection = electricCollection("user_presence_status", UserPresenceStatus.Schema) +export const userPresenceStatusCollection = electricCollection( + "user_presence_status", + UserPresenceStatus.Schema, +) export const integrationConnectionCollection = electricCollection( "integration_connections", IntegrationConnection.Schema, ) -export const chatSyncConnectionCollection = electricCollection("chat_sync_connections", ChatSyncConnection.Schema) -export const chatSyncChannelLinkCollection = electricCollection("chat_sync_channel_links", ChatSyncChannelLink.Schema) -export const chatSyncMessageLinkCollection = electricCollection("chat_sync_message_links", ChatSyncMessageLink.Schema) +export const chatSyncConnectionCollection = electricCollection( + "chat_sync_connections", + ChatSyncConnection.Schema, +) +export const chatSyncChannelLinkCollection = electricCollection( + "chat_sync_channel_links", + ChatSyncChannelLink.Schema, +) +export const chatSyncMessageLinkCollection = electricCollection( + "chat_sync_message_links", + ChatSyncMessageLink.Schema, +) export const botCollection = electricCollection("bots", Bot.Schema) export const botCommandCollection = electricCollection("bot_commands", BotCommand.Schema) export const botInstallationCollection = electricCollection("bot_installations", BotInstallation.Schema) diff --git a/apps/web-foldkit/src/core/lib/access-token.test.ts b/apps/web-foldkit/src/core/lib/access-token.test.ts index 28f7787c4..28b1621d0 100644 --- a/apps/web-foldkit/src/core/lib/access-token.test.ts +++ b/apps/web-foldkit/src/core/lib/access-token.test.ts @@ -83,6 +83,19 @@ describe("session token exchange", () => { stop() }) + it("treats a body that is not a token as an unanswered exchange", async () => { + vi.useFakeTimers() + const { seen, stop } = rejections() + const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json({ token: 42 })) + vi.stubGlobal("fetch", fetch) + const token = readAccessToken() + await vi.runAllTimersAsync() + expect(await token).toBe("unavailable") + expect(fetch).toHaveBeenCalledTimes(3) + expect(seen).not.toHaveBeenCalled() + stop() + }) + it("reports a refused session once, without retrying", async () => { const { seen, stop } = rejections() const fetch = vi.fn<typeof globalThis.fetch>(async () => new Response(null, { status: 401 })) diff --git a/apps/web-foldkit/src/core/lib/access-token.ts b/apps/web-foldkit/src/core/lib/access-token.ts index 6cc3c40ef..17a25ad6e 100644 --- a/apps/web-foldkit/src/core/lib/access-token.ts +++ b/apps/web-foldkit/src/core/lib/access-token.ts @@ -3,9 +3,13 @@ * (the Electric proxy and the Rivet actors). The backend exchanges the HttpOnly session * cookie for an ES256 JWT at `POST /auth/token`; those hosts verify it against the API's * JWKS. Requests to the API itself authenticate with the cookie and need no token. + * + * The exchange is an Effect; the exports stay Promise functions because their callers are fetch + * wrappers outside any runtime (Electric's `fetchClient`, the Rivet connection). */ -import { sessionAuthorization } from "./session-bearer" +import { Duration, Effect, Schedule, Schema } from "effect" +import { sessionAuthorizationEffect } from "./session-bearer" interface CachedToken { readonly token: string @@ -31,57 +35,70 @@ let pending: Promise<AccessTokenResult> | null = null */ export type AccessTokenResult = string | null | "unavailable" -const backendUrl = () => import.meta.env.VITE_BACKEND_URL || "http://localhost:3003" +/** The API could not answer the exchange: unreachable, a 5xx, or a body that isn't a token. */ +export class TokenExchangeError extends Schema.TaggedError<TokenExchangeError>()("TokenExchangeError", { + reason: Schema.Literals(["Unreachable", "ServerError", "BadResponse"]), + cause: Schema.optional(Schema.Defect()), +}) {} + +const TokenResponse = Schema.Struct({ token: Schema.String, expiresIn: Schema.Number }) +const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse) -const isTokenResponse = (value: unknown): value is { token: string; expiresIn: number } => - typeof value === "object" && - value !== null && - "token" in value && - typeof value.token === "string" && - "expiresIn" in value && - typeof value.expiresIn === "number" +const backendUrl = () => import.meta.env.VITE_BACKEND_URL || "http://localhost:3003" /** Attempts per exchange: a network error or 5xx right after sign-in is usually momentary. */ const EXCHANGE_ATTEMPTS = 3 -const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) - -/** `null` means the API answered and there is no session; `"unavailable"` means it could not answer. */ -const exchangeOnce = async (startedGeneration: number): Promise<AccessTokenResult> => { - try { - const authorization = await sessionAuthorization() - const response = await fetch(`${backendUrl()}/auth/token`, { - method: "POST", - // The desktop app's session is a bearer; a browser's is the API's cookie. - ...(authorization === null - ? { credentials: "include" as const, headers: { "x-effect-auth-csrf": "1" } } - : { credentials: "omit" as const, headers: { authorization } }), +const unreachable = (cause: unknown) => new TokenExchangeError({ reason: "Unreachable", cause }) + +/** A token, or `null` when the API answered and there is no session (or sign-out superseded this exchange). */ +const exchangeOnce = (startedGeneration: number): Effect.Effect<string | null, TokenExchangeError> => + Effect.gen(function* () { + const authorization = yield* sessionAuthorizationEffect.pipe(Effect.mapError(unreachable)) + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${backendUrl()}/auth/token`, { + method: "POST", + // The desktop app's session is a bearer; a browser's is the API's cookie. + ...(authorization === null + ? { credentials: "include" as const, headers: { "x-effect-auth-csrf": "1" } } + : { credentials: "omit" as const, headers: { authorization } }), + }), + catch: unreachable, }) if (startedGeneration !== generation) return null - if (response.status >= 500) return "unavailable" + if (response.status >= 500) return yield* new TokenExchangeError({ reason: "ServerError" }) if (!response.ok) { cached = null if (response.status === 401) window.dispatchEvent(new Event(SESSION_REJECTED_EVENT)) return null } - const body: unknown = await response.json() - if (startedGeneration !== generation || !isTokenResponse(body)) return null + const body = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new TokenExchangeError({ reason: "BadResponse", cause }), + }).pipe( + Effect.flatMap(decodeTokenResponse), + Effect.catchTag("SchemaError", (cause) => + Effect.fail(new TokenExchangeError({ reason: "BadResponse", cause })), + ), + ) + if (startedGeneration !== generation) return null cached = { token: body.token, expiresAtMs: Date.now() + body.expiresIn * 1000 } return body.token - } catch { - return "unavailable" - } -} - -const exchange = async (startedGeneration: number): Promise<AccessTokenResult> => { - for (let attempt = 1; ; attempt++) { - const result = await exchangeOnce(startedGeneration) - if (result !== "unavailable") return result - if (startedGeneration !== generation) return null - if (attempt >= EXCHANGE_ATTEMPTS) return "unavailable" - await sleep(250 * 2 ** attempt) - } -} + }) + +/** Retries an exchange the API did not answer (500ms, then 1s), unless sign-out superseded it. */ +const exchange = (startedGeneration: number): Effect.Effect<AccessTokenResult> => + exchangeOnce(startedGeneration).pipe( + Effect.retry({ + schedule: Schedule.exponential(Duration.millis(500)), + times: EXCHANGE_ATTEMPTS - 1, + while: () => startedGeneration === generation, + }), + Effect.catchTag("TokenExchangeError", () => + Effect.succeed<AccessTokenResult>(startedGeneration === generation ? "unavailable" : null), + ), + ) /** * The current access token, exchanged again shortly before it expires. Tells a refused session @@ -91,7 +108,7 @@ export const readAccessToken = async (): Promise<AccessTokenResult> => { if (typeof window === "undefined") return null if (cached !== null && cached.expiresAtMs - Date.now() > RENEW_BEFORE_MS) return cached.token if (pending === null) { - const request = exchange(generation).finally(() => { + const request = Effect.runPromise(exchange(generation)).finally(() => { if (pending === request) pending = null }) pending = request diff --git a/apps/web-foldkit/src/core/lib/bot-avatar.ts b/apps/web-foldkit/src/core/lib/bot-avatar.ts index 9cf6ce5fe..7adae54d4 100644 --- a/apps/web-foldkit/src/core/lib/bot-avatar.ts +++ b/apps/web-foldkit/src/core/lib/bot-avatar.ts @@ -1,63 +1,12 @@ import type { IntegrationConnection } from "@hazel/domain/models" -type IntegrationProvider = IntegrationConnection.IntegrationProvider - -/** - * Integration provider to Brandfetch domain mapping - */ -const INTEGRATION_DOMAINS: Record<IntegrationProvider, string> = { - linear: "linear.app", - github: "github.com", - figma: "figma.com", - notion: "notion.so", - discord: "discord.com", - craft: "craft.do", -} - -/** - * Integration provider to logo type mapping - * Some brands use "icon" for better results - */ -const INTEGRATION_LOGO_TYPE: Partial<Record<IntegrationProvider, "symbol" | "icon">> = { - figma: "icon", - notion: "icon", -} - -/** - * Generates a Brandfetch CDN URL for an integration provider - */ -export const getBrandfetchIcon = ( - domain: string, - options: { theme?: "light" | "dark"; size?: number; type?: "symbol" | "icon" } = {}, -): string => { - const { theme = "dark", size = 512, type = "symbol" } = options - return `https://cdn.brandfetch.io/${domain}/w/${size}/h/${size}/theme/${theme}/${type}?token=1id0IQ-4i8Z46-n-DfQ` -} - /** * Bot data interface for avatar resolution */ export interface BotAvatarData { user?: { avatarUrl?: string | null } | null - allowedIntegrations?: readonly IntegrationProvider[] | null + allowedIntegrations?: readonly IntegrationConnection.IntegrationProvider[] | null } -/** - * Smart avatar resolution for bots. - * - * Resolution order: - * 1. Machine user's avatarUrl if set - * 2. null (component should show robot icon fallback) - * - * @param bot - Bot data with user (containing avatarUrl) and allowedIntegrations - * @param theme - Theme for Brandfetch icons ("light" or "dark") - * @returns Avatar URL or null - */ -export function resolveBotAvatarUrl(bot: BotAvatarData, _theme: "light" | "dark" = "dark"): string | null { - // Use machine user's avatar if set, otherwise fallback to robot icon - if (bot.user?.avatarUrl) { - return bot.user.avatarUrl - } - - return null -} +/** A bot's avatar: its machine user's `avatarUrl`, or `null` for the robot icon fallback. */ +export const resolveBotAvatarUrl = (bot: BotAvatarData): string | null => bot.user?.avatarUrl || null diff --git a/apps/web-foldkit/src/core/lib/date-fns-shared.ts b/apps/web-foldkit/src/core/lib/date-fns-shared.ts deleted file mode 100644 index 4e6d05c22..000000000 --- a/apps/web-foldkit/src/core/lib/date-fns-shared.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** date-fns re-exported for the Foldkit app, which resolves packages through apps/web. */ -export { format, formatDistanceToNow } from "date-fns" diff --git a/apps/web-foldkit/src/core/lib/error-messages.ts b/apps/web-foldkit/src/core/lib/error-messages.ts index 2706a45e9..fd6edcc65 100644 --- a/apps/web-foldkit/src/core/lib/error-messages.ts +++ b/apps/web-foldkit/src/core/lib/error-messages.ts @@ -204,7 +204,10 @@ export interface UserErrorMessage { const COMMON_MESSAGES: { readonly [R in CommonReason]: UserErrorMessage } = { Offline: { title: "Connection lost", description: "Check your internet connection and try again." }, - Timeout: { title: "Request timed out", description: "The server is taking too long to respond. Please try again." }, + Timeout: { + title: "Request timed out", + description: "The server is taking too long to respond. Please try again.", + }, SignedOut: { title: "Your session has expired", description: "Please sign in again to continue." }, RateLimited: { title: "Rate limit exceeded", description: "Please wait before trying again." }, Unavailable: { title: "Service temporarily unavailable", description: "Please try again in a moment." }, @@ -231,4 +234,4 @@ export const failureMessage = <Reason extends string>( ): UserErrorMessage => isCommonReason(reason) ? commonMessage(reason) - : (specific as Record<string, UserErrorMessage>)[reason] ?? DEFAULT_ERROR_MESSAGE + : ((specific as Record<string, UserErrorMessage>)[reason] ?? DEFAULT_ERROR_MESSAGE) diff --git a/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts b/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts index ab0443a4b..60e7f49d3 100644 --- a/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts +++ b/apps/web-foldkit/src/core/lib/platform-storage/tauri-key-value-store.ts @@ -43,7 +43,10 @@ export const layerTauriStore: Layer.Layer<KeyValueStore.KeyValueStore> = Layer.e const withStore = <A>(method: string, key: string, run: (store: Store) => Promise<A>) => getStore.pipe( Effect.flatMap((store) => - Effect.tryPromise({ try: () => run(store), catch: (cause) => storeError(method, key, cause) }), + Effect.tryPromise({ + try: () => run(store), + catch: (cause) => storeError(method, key, cause), + }), ), ) diff --git a/apps/web-foldkit/src/core/lib/rivet-client.ts b/apps/web-foldkit/src/core/lib/rivet-client.ts index 95ad1d302..f0e2d034f 100644 --- a/apps/web-foldkit/src/core/lib/rivet-client.ts +++ b/apps/web-foldkit/src/core/lib/rivet-client.ts @@ -6,38 +6,5 @@ export { getAccessToken } const RIVET_URL = import.meta.env.VITE_RIVET_URL || "http://localhost:6420" +/** The Rivet actor client; actor connections pass the Hazel access token as `params.token`. */ export const rivetClient = createClient<Registry>(RIVET_URL) - -/** - * Get or create a message actor with authentication. - * The Hazel access token is passed as a connection parameter. - */ -export async function getAuthenticatedMessageActor( - key: string[], - createWithInput?: { initialData?: Record<string, unknown> }, -) { - const token = await getAccessToken() - if (!token) { - throw new Error("Authentication required: No access token available") - } - - return rivetClient.message.getOrCreate(key, { - params: { token }, - ...(createWithInput && { createWithInput }), - }) -} - -/** - * Create a message actor handle with a provided token. - * Use this when you already have the token available. - */ -export function getMessageActorWithToken( - key: string[], - token: string, - createWithInput?: { initialData?: Record<string, unknown> }, -) { - return rivetClient.message.getOrCreate(key, { - params: { token }, - ...(createWithInput && { createWithInput }), - }) -} diff --git a/apps/web-foldkit/src/core/lib/rpc-auth-middleware.ts b/apps/web-foldkit/src/core/lib/rpc-auth-middleware.ts index ce1edbef3..2e0fd72e9 100644 --- a/apps/web-foldkit/src/core/lib/rpc-auth-middleware.ts +++ b/apps/web-foldkit/src/core/lib/rpc-auth-middleware.ts @@ -5,12 +5,23 @@ import { FetchHttpClient, Headers } from "effect/http" import { RpcMiddleware } from "effect/rpc" import { AuthMiddleware } from "@hazel/domain/rpc" +import { SessionLoadError } from "@hazel/domain/errors" import { Effect, Layer } from "effect" -import { sessionAuthorization } from "./session-bearer" +import { sessionAuthorizationEffect } from "./session-bearer" export const AuthMiddlewareClientLive = RpcMiddleware.layerClient(AuthMiddleware, ({ request, next }) => Effect.gen(function* () { - const authorization = yield* Effect.promise(sessionAuthorization) + const authorization = yield* sessionAuthorizationEffect.pipe( + // An unreadable desktop session is the auth middleware's own "could not load the session". + Effect.catchTag("SessionBearerError", () => + Effect.fail( + new SessionLoadError({ + message: "Could not read the desktop session", + detail: "The session store did not answer", + }), + ), + ), + ) return yield* next( authorization === null ? request diff --git a/apps/web-foldkit/src/core/lib/session-bearer.ts b/apps/web-foldkit/src/core/lib/session-bearer.ts index 1cab971a9..cf0c821d9 100644 --- a/apps/web-foldkit/src/core/lib/session-bearer.ts +++ b/apps/web-foldkit/src/core/lib/session-bearer.ts @@ -1,3 +1,5 @@ +import { Effect, Schema } from "effect" + /** * The desktop app's session, for requests that can't carry the API's session cookie. The * desktop app registers a provider at startup; in a browser none is set and requests rely @@ -10,9 +12,22 @@ export const setSessionBearerProvider = (next: () => Promise<string | null>) => provider = next } +/** The desktop provider could not read the stored session (keychain or store failure). */ +export class SessionBearerError extends Schema.TaggedError<SessionBearerError>()("SessionBearerError", { + cause: Schema.Defect(), +}) {} + /** `Authorization` for the current session, or `null` (browser, or signed out). */ -export const sessionAuthorization = async (): Promise<string | null> => { - if (provider === null) return null - const credential = await provider() - return credential === null ? null : `Bearer ${credential}` -} +export const sessionAuthorizationEffect: Effect.Effect<string | null, SessionBearerError> = Effect.suspend( + () => { + const current = provider + if (current === null) return Effect.succeed(null) + return Effect.tryPromise({ try: current, catch: (cause) => new SessionBearerError({ cause }) }).pipe( + Effect.map((credential) => (credential === null ? null : `Bearer ${credential}`)), + ) + }, +) + +/** `sessionAuthorizationEffect` for Promise callers (fetch wrappers); rejects with `SessionBearerError`. */ +export const sessionAuthorization = (): Promise<string | null> => + Effect.runPromise(sessionAuthorizationEffect) diff --git a/apps/web-foldkit/src/core/utils/image-crop.ts b/apps/web-foldkit/src/core/utils/image-crop.ts index 5308a5d0b..ab533891d 100644 --- a/apps/web-foldkit/src/core/utils/image-crop.ts +++ b/apps/web-foldkit/src/core/utils/image-crop.ts @@ -1,3 +1,5 @@ +import { Effect, Schema } from "effect" + /** * Image cropping utilities using native Canvas API */ @@ -8,50 +10,60 @@ export interface CropRect { size: number // width and height (square) } +/** The image would not load, or the canvas could not crop it (no 2D context, or it would not encode). */ +export class CropError extends Schema.TaggedError<CropError>()("CropError", { + reason: Schema.Literals(["LoadFailed", "NoCanvasContext", "EncodeFailed"]), +}) {} + +/** Loads an image element from a (blob) URL. */ +export const loadImage = (src: string): Effect.Effect<HTMLImageElement, CropError> => + Effect.callback<HTMLImageElement, CropError>((resume) => { + const image = new Image() + image.onload = () => resume(Effect.succeed(image)) + image.onerror = () => resume(Effect.fail(new CropError({ reason: "LoadFailed" }))) + image.src = src + }) + /** - * Crops an image to the specified rectangle and outputs at target dimensions + * Crops an image to the specified rectangle and outputs at target dimensions (WebP) */ -export async function cropImage( +export const cropImage = ( image: HTMLImageElement, cropRect: CropRect, outputSize: number, -): Promise<Blob> { - const canvas = document.createElement("canvas") - canvas.width = outputSize - canvas.height = outputSize +): Effect.Effect<Blob, CropError> => + Effect.suspend(() => { + const canvas = document.createElement("canvas") + canvas.width = outputSize + canvas.height = outputSize - const ctx = canvas.getContext("2d") - if (!ctx) { - throw new Error("Could not get canvas context") - } + const ctx = canvas.getContext("2d") + if (!ctx) return Effect.fail(new CropError({ reason: "NoCanvasContext" })) - // Draw the cropped portion scaled to output size - ctx.drawImage( - image, - cropRect.x, // source x - cropRect.y, // source y - cropRect.size, // source width - cropRect.size, // source height - 0, // dest x - 0, // dest y - outputSize, // dest width - outputSize, // dest height - ) - - return new Promise((resolve, reject) => { - canvas.toBlob( - (blob) => { - if (blob) { - resolve(blob) - } else { - reject(new Error("Failed to create blob from canvas")) - } - }, - "image/webp", - 0.9, // Quality + // Draw the cropped portion scaled to output size + ctx.drawImage( + image, + cropRect.x, // source x + cropRect.y, // source y + cropRect.size, // source width + cropRect.size, // source height + 0, // dest x + 0, // dest y + outputSize, // dest width + outputSize, // dest height ) + + return Effect.callback<Blob, CropError>((resume) => { + canvas.toBlob( + (blob) => + resume( + blob ? Effect.succeed(blob) : Effect.fail(new CropError({ reason: "EncodeFailed" })), + ), + "image/webp", + 0.9, // Quality + ) + }) }) -} /** * Calculates the initial crop rectangle (largest centered square) diff --git a/apps/web-foldkit/src/data/actions.test.ts b/apps/web-foldkit/src/data/actions.test.ts index af918575a..ce643ce1a 100644 --- a/apps/web-foldkit/src/data/actions.test.ts +++ b/apps/web-foldkit/src/data/actions.test.ts @@ -26,8 +26,12 @@ const Failed = (reason: string) => ({ _tag: "Failed" as const, reason }) describe("catchCommon", () => { test("a common error becomes its reason", async () => { - expect(await Effect.runPromise(Effect.fail(offline).pipe(catchCommon(Failed)))).toEqual(Failed("Offline")) - expect(await Effect.runPromise(Effect.fail(rpcOffline).pipe(catchCommon(Failed)))).toEqual(Failed("Offline")) + expect(await Effect.runPromise(Effect.fail(offline).pipe(catchCommon(Failed)))).toEqual( + Failed("Offline"), + ) + expect(await Effect.runPromise(Effect.fail(rpcOffline).pipe(catchCommon(Failed)))).toEqual( + Failed("Offline"), + ) expect(await Effect.runPromise(Effect.fail(unauthorized).pipe(catchCommon(Failed)))).toEqual( Failed("SignedOut"), ) @@ -92,9 +96,11 @@ describe("failure toasts", () => { }) test("a handler function builds the message from the typed error", () => { - expect(failureToast(unhandled, "exitToast", { ChannelNotFoundError: (error) => ({ title: error._tag }) })).toEqual( - errorToast("ChannelNotFoundError"), - ) + expect( + failureToast(unhandled, "exitToast", { + ChannelNotFoundError: (error) => ({ title: error._tag }), + }), + ).toEqual(errorToast("ChannelNotFoundError")) }) test("an unknown error never shows its own message", () => { @@ -105,7 +111,9 @@ describe("failure toasts", () => { test("a common error gets the shared copy", () => { const copy = commonMessage("SignedOut") - expect(failureToast(Cause.fail(unauthorized), "friendly")).toEqual(errorToast(copy.title, copy.description)) + expect(failureToast(Cause.fail(unauthorized), "friendly")).toEqual( + errorToast(copy.title, copy.description), + ) const offlineCopy = commonMessage("Offline") expect(failureToast(Cause.fail(offline), "exitToast")).toEqual( errorToast(offlineCopy.title, offlineCopy.description), @@ -115,7 +123,9 @@ describe("failure toasts", () => { test("a defect never shows its message", () => { const toast = failureToast(Cause.die(new Error("boom")), "friendly") expect(toast.title).not.toContain("boom") - expect(toast).toEqual(errorToast(commonMessage("Unexpected").title, commonMessage("Unexpected").description)) + expect(toast).toEqual( + errorToast(commonMessage("Unexpected").title, commonMessage("Unexpected").description), + ) }) test("success and error toasts default to no description", () => { diff --git a/apps/web-foldkit/src/editor/decorations.ts b/apps/web-foldkit/src/editor/decorations.ts index 5f85d971c..7849c21ad 100644 --- a/apps/web-foldkit/src/editor/decorations.ts +++ b/apps/web-foldkit/src/editor/decorations.ts @@ -60,7 +60,9 @@ export function decorateMarkdown(text: string): ReadonlyArray<MarkdownRange> { for (const match of text.matchAll(URL_PATTERN)) { const index = match.index - const insideLink = ranges.some((range) => range.type === "link" && index >= range.start && index < range.end) + const insideLink = ranges.some( + (range) => range.type === "link" && index >= range.start && index < range.end, + ) if (insideLink) continue const url = match[0] ranges.push({ @@ -78,7 +80,8 @@ export function decorateMarkdown(text: string): ReadonlyArray<MarkdownRange> { if (!openMarker || !content || !closeMarker) continue const index = match.index const overlapsLink = ranges.some( - (range) => range.type === "link" && index < range.end && index + fullMatch.length > range.start, + (range) => + range.type === "link" && index < range.end && index + fullMatch.length > range.start, ) if (overlapsLink) continue const contentStart = index + openMarker.length @@ -159,7 +162,10 @@ export function decorateEmoji(text: string): ReadonlyArray<EmojiRange> { } /** Slate's `Text.decorations`: split the text at range edges and `Object.assign` each range in order. */ -export function splitLeaves(text: string, ranges: ReadonlyArray<MarkdownRange | EmojiRange>): ReadonlyArray<Leaf> { +export function splitLeaves( + text: string, + ranges: ReadonlyArray<MarkdownRange | EmojiRange>, +): ReadonlyArray<Leaf> { type Mutable = { text: string; props: LeafProps } let leaves: Array<Mutable> = [{ text, props: {} }] for (const { start: decorationStart, end: decorationEnd, ...props } of ranges) { diff --git a/apps/web-foldkit/src/notification-sound.test.ts b/apps/web-foldkit/src/notification-sound.test.ts index d9eaa7eef..234a91b06 100644 --- a/apps/web-foldkit/src/notification-sound.test.ts +++ b/apps/web-foldkit/src/notification-sound.test.ts @@ -55,7 +55,9 @@ describe("quiet hours", () => { nowMs: at("12:00"), } expect(isSoundMuted(inputs)).toBe(false) - expect(isSoundMuted({ ...inputs, settings: { ...DEFAULT_SOUND_SETTINGS, enabled: false } })).toBe(true) + expect(isSoundMuted({ ...inputs, settings: { ...DEFAULT_SOUND_SETTINGS, enabled: false } })).toBe( + true, + ) expect(isSoundMuted({ ...inputs, doNotDisturb: true })).toBe(true) expect(isSoundMuted({ ...inputs, nowMs: at("23:00") })).toBe(true) }) diff --git a/apps/web-foldkit/src/notification-sound.ts b/apps/web-foldkit/src/notification-sound.ts index 881f9c279..ddc2cdd07 100644 --- a/apps/web-foldkit/src/notification-sound.ts +++ b/apps/web-foldkit/src/notification-sound.ts @@ -39,7 +39,11 @@ const minutesOfDay = (time: string): number => { } /** Whether `nowMs` (local time) falls in the user's quiet hours; a window may span midnight. */ -export const isInQuietHours = (quietHoursStart: string | null, quietHoursEnd: string | null, nowMs: number): boolean => { +export const isInQuietHours = ( + quietHoursStart: string | null, + quietHoursEnd: string | null, + nowMs: number, +): boolean => { if (!quietHoursStart || !quietHoursEnd) return false const now = new Date(nowMs) const current = now.getHours() * 60 + now.getMinutes() @@ -58,5 +62,11 @@ export interface SoundMuteInputs { } /** Sounds are muted when disabled, in do not disturb, or during quiet hours. */ -export const isSoundMuted = ({ settings, doNotDisturb, quietHoursStart, quietHoursEnd, nowMs }: SoundMuteInputs) => +export const isSoundMuted = ({ + settings, + doNotDisturb, + quietHoursStart, + quietHoursEnd, + nowMs, +}: SoundMuteInputs) => !settings?.enabled || doNotDisturb || isInQuietHours(quietHoursStart, quietHoursEnd, nowMs) diff --git a/apps/web-foldkit/src/overlay/command-palette/commands.ts b/apps/web-foldkit/src/overlay/command-palette/commands.ts index 5bccecbff..32cf30244 100644 --- a/apps/web-foldkit/src/overlay/command-palette/commands.ts +++ b/apps/web-foldkit/src/overlay/command-palette/commands.ts @@ -92,8 +92,9 @@ export const TrackRecentChannel = Command.define("TrackRecentChannel", { MAX_RECENT_CHANNELS, ), ), - Effect.flatMap((channels) => Effect.sync(() => localStorage.setItem(RECENT_CHANNELS_KEY, JSON.stringify(channels)))), - Effect.ignoreCause, + // Best effort: a full or blocked localStorage only loses the recent list. + Effect.flatMap((channels) => Effect.try(() => localStorage.setItem(RECENT_CHANNELS_KEY, JSON.stringify(channels)))), + Effect.ignore, Effect.as(Message.CompletedTrackRecentChannel()), ), }) diff --git a/apps/web-foldkit/src/page/chat/pinned.ts b/apps/web-foldkit/src/page/chat/pinned.ts index 19cfdee5c..91d241178 100644 --- a/apps/web-foldkit/src/page/chat/pinned.ts +++ b/apps/web-foldkit/src/page/chat/pinned.ts @@ -1,5 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { format } from "~/lib/date-fns-shared" +import { format } from "date-fns" import { IconClose, IconPin } from "../../icons" import { avatar } from "../../ui/avatar" import * as Popover from "../../ui/popover" diff --git a/apps/web-foldkit/src/page/chat/thread-panel.ts b/apps/web-foldkit/src/page/chat/thread-panel.ts index 8b8ba9c39..4f9c6162c 100644 --- a/apps/web-foldkit/src/page/chat/thread-panel.ts +++ b/apps/web-foldkit/src/page/chat/thread-panel.ts @@ -1,6 +1,6 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { handleStyles, indicatorStyles, panelStyles } from "~/components/ui/split-panel/split-panel.styles" -import { format } from "~/lib/date-fns-shared" +import { format } from "date-fns" import { messageRowView, type RowContext } from "../../chat/message/row" import { markdownView } from "../../chat/markdown/markdown-view" import { IconClose, IconEdit, IconPenSparkle } from "../../icons" diff --git a/apps/web-foldkit/src/page/my-settings/profile/command.ts b/apps/web-foldkit/src/page/my-settings/profile/command.ts index 9094f7bea..f31b07173 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/command.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/command.ts @@ -2,7 +2,7 @@ import { UserId } from "@hazel/schema" import { Effect, Schema } from "effect" import { Command } from "foldkit" import { File } from "foldkit/file" -import { calculateInitialCrop, cropImage } from "~/utils/image-crop" +import { calculateInitialCrop, cropImage, loadImage } from "~/utils/image-crop" import { detectBrowserTimezone } from "~/utils/timezone" import { publicUrlOf, uploadFile } from "../../settings/upload" import { updateUser } from "../user" @@ -27,14 +27,6 @@ export const OpenFilePicker = Command.define("OpenFilePicker", { }), }) -const loadImage = (src: string) => - Effect.callback<HTMLImageElement, Error>((resume) => { - const image = new Image() - image.onload = () => resume(Effect.succeed(image)) - image.onerror = () => resume(Effect.fail(new Error("Failed to load image"))) - image.src = src - }) - /** `AvatarCropModal`'s effect: an object URL for the file, then the largest centered square. */ export const LoadCropImage = Command.define("LoadCropImage", { args: { file: File, loadId: Schema.Number }, @@ -79,7 +71,7 @@ export const CropAvatarImage = Command.define("CropAvatarImage", { messages: [Message.CompletedCropImage], execute: ({ src, crop }) => loadImage(src).pipe( - Effect.flatMap((image) => Effect.tryPromise(() => cropImage(image, crop, OUTPUT_SIZE))), + Effect.flatMap((image) => cropImage(image, crop, OUTPUT_SIZE)), Effect.map((blob) => Message.CompletedCropImage({ blob })), Effect.catch(() => Effect.succeed(Message.CompletedCropImage({ blob: null }))), ), diff --git a/libs/effect-electric-db-collection/src/collection.ts b/libs/effect-electric-db-collection/src/collection.ts index 7e70f8203..330e43dd3 100644 --- a/libs/effect-electric-db-collection/src/collection.ts +++ b/libs/effect-electric-db-collection/src/collection.ts @@ -6,7 +6,7 @@ import type { ElectricCollectionUtils, Txid } from "@tanstack/electric-db-collec import { electricCollectionOptions } from "@tanstack/electric-db-collection" import { createCollection as tanstackCreateCollection } from "@tanstack/db" import { Effect, type ManagedRuntime, Schema } from "effect" -import { AwaitTxIdError, InvalidTxIdError, MaxRetriesExceededError, TxIdTimeoutError } from "./errors" +import { AwaitTxIdError, InvalidTxIdError, TxIdTimeoutError } from "./errors" import { convertDeleteHandler, convertInsertHandler, convertUpdateHandler } from "./handlers" import type { BackoffConfig, EffectElectricCollectionConfig } from "./types" diff --git a/libs/effect-electric-db-collection/src/optimistic-action.ts b/libs/effect-electric-db-collection/src/optimistic-action.ts index 847648764..77fe8bbaa 100644 --- a/libs/effect-electric-db-collection/src/optimistic-action.ts +++ b/libs/effect-electric-db-collection/src/optimistic-action.ts @@ -2,7 +2,13 @@ import type { Collection, Transaction } from "@tanstack/db" import { createTransaction } from "@tanstack/db" import type { Txid } from "@tanstack/electric-db-collection" import { type Cause, Effect, Exit } from "effect" -import { type AwaitTxIdError, type InvalidTxIdError, OptimisticActionError, SyncError, type TxIdTimeoutError } from "./errors" +import { + type AwaitTxIdError, + type InvalidTxIdError, + OptimisticActionError, + SyncError, + type TxIdTimeoutError, +} from "./errors" /** * Collection with Effect-native utilities. @@ -200,8 +206,15 @@ export function optimisticAction< mutationFn: async (params) => { const exit = await Effect.runPromiseExit( Effect.gen(function* () { - mutationResult = yield* mutate(variables, { mutateResult, transaction: params.transaction }) - yield* syncAllCollections(normalizedCollections, mutationResult.transactionId, syncTimeout) + mutationResult = yield* mutate(variables, { + mutateResult, + transaction: params.transaction, + }) + yield* syncAllCollections( + normalizedCollections, + mutationResult.transactionId, + syncTimeout, + ) return mutationResult.data }).pipe(Effect.provideContext(context)), ) @@ -219,7 +232,8 @@ export function optimisticAction< const persisted = yield* Effect.exit( Effect.tryPromise({ try: () => transaction.isPersisted.promise, - catch: (cause) => new OptimisticActionError({ message: "Optimistic action failed", cause }), + catch: (cause) => + new OptimisticActionError({ message: "Optimistic action failed", cause }), }), ) @@ -229,7 +243,9 @@ export function optimisticAction< // Best effort: the failure below is what the caller needs to see. yield* Effect.ignore(Effect.try(() => transaction.rollback())) } - return yield* failure === undefined ? Effect.failCause(persisted.cause) : Effect.failCause(failure) + return yield* failure === undefined + ? Effect.failCause(persisted.cause) + : Effect.failCause(failure) } return { From 7af15cf454d35bc59a07f200faa3e4be387bb2d7 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:00:23 +0200 Subject: [PATCH 11/52] docs(web): finish the error convention; storage reads catch their own tags Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/foldkit-decisions/s6-errors.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/foldkit-decisions/s6-errors.md b/docs/foldkit-decisions/s6-errors.md index 9b7fb9915..190f97676 100644 --- a/docs/foldkit-decisions/s6-errors.md +++ b/docs/foldkit-decisions/s6-errors.md @@ -26,14 +26,14 @@ FailedRenameChannel: { reason: RenameFailure }, `CommonReason` (from `~/lib/error-messages`) is the closed set every RPC / HttpApi / optimistic-action failure collapses to: -| reason | from | -| --- | --- | -| `Offline` | `HttpClientError` with `reason._tag === "TransportError"`, `RpcClientError` whose HTTP `kind` is `TransportError`, socket errors | -| `Timeout` | `TimeoutError`, `TxIdTimeoutError` | -| `SignedOut` | `UnauthorizedError`(401 middleware) / `Session*Error` / `InvalidJwtPayloadError` / `InvalidBearerTokenError` | -| `RateLimited` | `RateLimitExceededError` | -| `Unavailable` | `SessionLoadError`, `WorkflowServiceUnavailableError`, 5xx `StatusCodeError`, `CollectionSyncEffectError` | -| `Unexpected` | `InternalServerError`, `SchemaError`, decode/encode errors, `OptimisticActionError`, TanStack key errors, defects | +| reason | from | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `Offline` | `HttpClientError` with `reason._tag === "TransportError"`, `RpcClientError` whose HTTP `kind` is `TransportError`, socket errors | +| `Timeout` | `TimeoutError`, `TxIdTimeoutError` | +| `SignedOut` | `UnauthorizedError`(401 middleware) / `Session*Error` / `InvalidJwtPayloadError` / `InvalidBearerTokenError` | +| `RateLimited` | `RateLimitExceededError` | +| `Unavailable` | `SessionLoadError`, `WorkflowServiceUnavailableError`, 5xx `StatusCodeError`, `CollectionSyncEffectError` | +| `Unexpected` | `InternalServerError`, `SchemaError`, decode/encode errors, `OptimisticActionError`, TanStack key errors, defects | ## 2. Building the reason in the Command: `Effect.catchTags` + `catchCommon` @@ -48,8 +48,10 @@ const RenameChannel = Command.define("RenameChannel", { Effect.as(Message.SucceededRenameChannel()), // 1. the errors this feature has words for Effect.catchTags({ - ChannelNotFoundError: () => Effect.succeed(Message.FailedRenameChannel({ reason: "NotFound" })), - ChannelNameTakenError: () => Effect.succeed(Message.FailedRenameChannel({ reason: "NameTaken" })), + ChannelNotFoundError: () => + Effect.succeed(Message.FailedRenameChannel({ reason: "NotFound" })), + ChannelNameTakenError: () => + Effect.succeed(Message.FailedRenameChannel({ reason: "NameTaken" })), }), // 2. everything left must be a CommonError (compile error otherwise), plus defects catchCommon((reason) => Message.FailedRenameChannel({ reason })), From e6168f9efd0bfe8b3ccc8191be4e9d2a73c7a93f Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:01:59 +0200 Subject: [PATCH 12/52] refactor(web): Slider and Tabs on @foldkit/ui, with the value owned by the parent The kit Slider and Tabs are now @foldkit/ui 0.167 Submodels with a styled toView (ui/slider.ts layout, ui/tabs.ts layout). The notifications page passes Shared.soundSettings.volume as the Slider's value and stores ChangedValue, so syncVolume and sharedChanged are gone; the chat index keeps its selected tab in its own Model and folds Tabs' Selected OutMessage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/core/components/ui/slider.styles.ts | 25 +- apps/web-foldkit/src/page/chat-index/model.ts | 7 + .../src/page/chat-index/page.test.ts | 9 +- .../src/page/chat-index/scene.test.ts | 7 +- .../web-foldkit/src/page/chat-index/update.ts | 12 +- apps/web-foldkit/src/page/chat-index/view.ts | 47 +- .../page/my-settings/notifications/command.ts | 2 +- .../page/my-settings/notifications/index.ts | 4 +- .../my-settings/notifications/scene.test.ts | 10 +- .../my-settings/notifications/story.test.ts | 45 +- .../page/my-settings/notifications/update.ts | 54 +-- .../page/my-settings/notifications/view.ts | 34 +- apps/web-foldkit/src/ui/slider.scene.test.ts | 161 +++--- apps/web-foldkit/src/ui/slider.story.test.ts | 145 ------ apps/web-foldkit/src/ui/slider.ts | 458 ++---------------- apps/web-foldkit/src/ui/tabs.scene.test.ts | 141 +++--- apps/web-foldkit/src/ui/tabs.story.test.ts | 110 ----- apps/web-foldkit/src/ui/tabs.ts | 309 +++--------- 18 files changed, 354 insertions(+), 1226 deletions(-) delete mode 100644 apps/web-foldkit/src/ui/slider.story.test.ts delete mode 100644 apps/web-foldkit/src/ui/tabs.story.test.ts 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 c5013df45..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,7 +1,4 @@ -/** 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 data-disabled:opacity-50", "data-[orientation=horizontal]:w-full data-[orientation=horizontal]:min-w-fit data-[orientation=horizontal]:gap-y-2", @@ -10,8 +7,9 @@ export const sliderStyles = [ 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 = [ @@ -22,19 +20,4 @@ export const sliderTrackStyles = [ ] export const sliderFillStyles = - "group-orientation-horizontal/top-0 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" - -/** SliderFill's inline style for one thumb (a width) or a range (offset plus width). */ -export const sliderFillStyle = ( - orientation: "horizontal" | "vertical", - percents: ReadonlyArray<number>, -): Readonly<Record<string, string>> => { - 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/page/chat-index/model.ts b/apps/web-foldkit/src/page/chat-index/model.ts index 205dd1878..d22a33f8b 100644 --- a/apps/web-foldkit/src/page/chat-index/model.ts +++ b/apps/web-foldkit/src/page/chat-index/model.ts @@ -37,17 +37,24 @@ export const Presence = Schema.Struct({ }) export type Presence = typeof Presence.Type +export const ChatIndexTab = Schema.Literals(["public", "private", "dms"]) +export type ChatIndexTab = typeof ChatIndexTab.Type + export const Model = Schema.Struct({ /** `null` while the live query loads (the legacy full-height loader). */ groups: Schema.NullOr(ChannelGroups), /** `useUserPresence` for the other member of each single DM, keyed by user id. */ presence: Schema.Record(Schema.String, Presence), + tab: ChatIndexTab, + /** The tab list's roving focus; the selected tab is `tab`. */ tabs: Tabs.Model, }) export type Model = typeof Model.Type export const TABS_ID = "chat-index-tabs" +export const ChatIndexTabs = Tabs.create<ChatIndexTab>() + /** The other members of a DM, as `DmCard` filters them. */ export const otherMembers = (channel: ChannelSummary, currentUserId: string | undefined) => channel.members.filter((member) => member.userId !== currentUserId) diff --git a/apps/web-foldkit/src/page/chat-index/page.test.ts b/apps/web-foldkit/src/page/chat-index/page.test.ts index 475d9bffd..d38c1f590 100644 --- a/apps/web-foldkit/src/page/chat-index/page.test.ts +++ b/apps/web-foldkit/src/page/chat-index/page.test.ts @@ -1,6 +1,6 @@ import { ChannelId, UserId } from "@hazel/schema" import { Schema } from "effect" -import { expectOutMessage, given, message, model, story } from "foldkit/story" +import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import * as Tabs from "../../ui/tabs" import { PageOutMessage } from "../out-message" @@ -69,12 +69,13 @@ describe("chat index grouping", () => { }) describe("chat index update", () => { - test("selects a tab on press", () => { + test("selecting a tab stores it in the page Model", () => { story( update, given(init().model), - message(Message.GotTabsMessage({ message: Tabs.Message.PressedTab({ key: "dms" }) })), - model((current) => expect(current.tabs.selectedKey).toBe("dms")), + message(Message.GotTabsMessage({ message: Tabs.Message.SelectedTab({ index: 2, value: "dms" }) })), + Command.resolve(Tabs.FocusTab, Tabs.Message.CompletedFocusTab()), + model((current) => expect(current.tab).toBe("dms")), ) }) diff --git a/apps/web-foldkit/src/page/chat-index/scene.test.ts b/apps/web-foldkit/src/page/chat-index/scene.test.ts index 96725831b..f1a352258 100644 --- a/apps/web-foldkit/src/page/chat-index/scene.test.ts +++ b/apps/web-foldkit/src/page/chat-index/scene.test.ts @@ -5,6 +5,7 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { shared as memberShared } from "../chat/channel/fixtures.test-support" import type { Shared } from "../contract" +import * as Tabs from "../../ui/tabs" import { PageOutMessage } from "../out-message" import { Message } from "./message" import type { ChannelGroups, ChannelSummary, Model } from "./model" @@ -61,7 +62,8 @@ describe("chat index scene", () => { sceneFor(admin), Scene.given(init().model), Scene.Subscription.emit(Message.UpdatedChannels({ groups })), - Scene.pointerDown(Scene.role("tab", { name: /Private/ })), + Scene.click(Scene.role("tab", { name: /Private/ })), + Scene.Command.resolve(Tabs.FocusTab, Tabs.Message.CompletedFocusTab()), Scene.expect(Scene.text("No private channels")).toExist(), Scene.click(Scene.role("button", { name: /Create a private channel$/ })), Scene.expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "NewChannel" } })), @@ -73,7 +75,8 @@ describe("chat index scene", () => { config, Scene.given(init().model), Scene.Subscription.emit(Message.UpdatedChannels({ groups })), - Scene.pointerDown(Scene.role("tab", { name: /Private/ })), + Scene.click(Scene.role("tab", { name: /Private/ })), + Scene.Command.resolve(Tabs.FocusTab, Tabs.Message.CompletedFocusTab()), Scene.expect(Scene.text("No private channels")).toExist(), Scene.expect(Scene.role("button", { name: /Create a private channel$/ })).toBeAbsent(), ) diff --git a/apps/web-foldkit/src/page/chat-index/update.ts b/apps/web-foldkit/src/page/chat-index/update.ts index 183d22990..4c1e41339 100644 --- a/apps/web-foldkit/src/page/chat-index/update.ts +++ b/apps/web-foldkit/src/page/chat-index/update.ts @@ -4,10 +4,10 @@ import * as Tabs from "../../ui/tabs" import type { PageReturn } from "../contract" import { PageOutMessage } from "../out-message" import { Message } from "./message" -import { type Model, TABS_ID } from "./model" +import { ChatIndexTabs, type Model, TABS_ID } from "./model" export const init = (): PageReturn<Model, Message> => ({ - model: { groups: null, presence: {}, tabs: Tabs.init({ id: TABS_ID, selectedKey: "public" }) }, + model: { groups: null, presence: {}, tab: "public", tabs: Tabs.init({ id: TABS_ID }) }, }) export const update = (model: Model, message: Message): PageReturn<Model, Message> => @@ -15,9 +15,13 @@ export const update = (model: Model, message: Message): PageReturn<Model, Messag UpdatedChannels: ({ groups }) => ({ model: modifyFields(model, { groups: () => groups }) }), UpdatedPresence: ({ presence }) => ({ model: modifyFields(model, { presence: () => presence }) }), GotTabsMessage: ({ message: tabsMessage }) => { - const result = Tabs.update(model.tabs, tabsMessage) + const result = ChatIndexTabs.update(model.tabs, tabsMessage) + const selected = result.outMessage return { - model: modifyFields(model, { tabs: () => result.model }), + model: modifyFields(model, { + tabs: () => result.model, + tab: (tab) => (selected === undefined ? tab : selected.value), + }), commands: Command.mapMessages(result.commands ?? [], (child) => Message.GotTabsMessage({ message: child }), ), diff --git a/apps/web-foldkit/src/page/chat-index/view.ts b/apps/web-foldkit/src/page/chat-index/view.ts index 4a17534cc..4d09b556c 100644 --- a/apps/web-foldkit/src/page/chat-index/view.ts +++ b/apps/web-foldkit/src/page/chat-index/view.ts @@ -16,12 +16,22 @@ import * as Tabs from "../../ui/tabs" import { can, type PageViewInputs, type Shared } from "../contract" import { channelCard, dmCard } from "./cards" import { Message } from "./message" -import type { ChannelGroups, ChannelSummary, Model } from "./model" +import { type ChannelGroups, type ChannelSummary, ChatIndexTabs, type ChatIndexTab, type Model } from "./model" /** Port of `routes/_app/$orgSlug/chat/index.tsx`. */ const toTabsMessage = (message: Tabs.Message) => Message.GotTabsMessage({ message }) +const TABS: ReadonlyArray<ChatIndexTab> = ["public", "private", "dms"] +const TAB_LABELS: Record<ChatIndexTab, string> = { + public: "Public", + private: "Private", + dms: "Direct messages", +} +const countOf = (groups: ChannelGroups, tab: ChatIndexTab) => + (tab === "public" ? groups.publicChannels : tab === "private" ? groups.privateChannels : groups.dmChannels) + .length + const tabLabel = (h: HtmlBuilder<Message>, label: string, count: number): Html => h.span( [h.Class("flex items-center gap-2")], @@ -161,26 +171,23 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, h.submodel({ slotId: "tabs", model: model.tabs, - view: Tabs.view, + view: ChatIndexTabs.view, viewInputs: { - listClassName: "w-full", - tabs: [ - { key: "public", content: [tabLabel(h, "Public", groups.publicChannels.length)] }, - { key: "private", content: [tabLabel(h, "Private", groups.privateChannels.length)] }, - { key: "dms", content: [tabLabel(h, "Direct messages", groups.dmChannels.length)] }, - ], - panels: [ - { key: "public", content: [] }, - { key: "private", content: [] }, - { key: "dms", content: [] }, - ], - // The cards link into the org, so the panels wait for its slug. - renderPanel: (key: string) => - shared.orgSlug === null - ? [] - : (panels(h, model, groups, shared, shared.orgSlug).find( - (panel) => panel.key === key, - )?.content ?? []), + tabs: TABS, + selectedValue: model.tab, + ariaLabel: "Channels", + toView: (render) => + Tabs.layout(h, render, { + listClassName: "w-full", + tab: (tab) => [tabLabel(h, TAB_LABELS[tab], countOf(groups, tab))], + // The cards link into the org, so the panels wait for its slug. + panel: (tab) => + shared.orgSlug === null + ? [] + : (panels(h, model, groups, shared, shared.orgSlug).find( + (panel) => panel.key === tab, + )?.content ?? []), + }), }, toParentMessage: toTabsMessage, }), diff --git a/apps/web-foldkit/src/page/my-settings/notifications/command.ts b/apps/web-foldkit/src/page/my-settings/notifications/command.ts index 088254413..ee8b37eb6 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/command.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/command.ts @@ -1,6 +1,6 @@ import { User } from "@hazel/domain/models" import { UserId } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { testNativeNotification } from "~/lib/native-notifications" import { notificationSoundManager } from "~/lib/notification-sound-manager" diff --git a/apps/web-foldkit/src/page/my-settings/notifications/index.ts b/apps/web-foldkit/src/page/my-settings/notifications/index.ts index 424f5b7be..3f0c9893b 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/index.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/index.ts @@ -2,12 +2,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" import { subscriptions } from "./subscription" -import { init, sharedChanged, update } from "./update" +import { init, update } from "./update" import { view } from "./view" /** `/$orgSlug/my-settings/notifications` */ export const page = definePage( "MySettingsNotifications", { Model, Message }, - { routes: ["MySettingsNotifications"], init, update, view, subscriptions, sharedChanged }, + { routes: ["MySettingsNotifications"], init, update, view, subscriptions }, ) diff --git a/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts b/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts index 205b1559b..8f5ed38b5 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts @@ -30,7 +30,7 @@ describe("do not disturb", () => { test("the switch flips at once and saves it merged into the synced settings", () => { Scene.scene( pageScene(update, view, shared), - Scene.given(init(undefined, shared).model), + Scene.given(init().model), Scene.Subscription.emit(Message.UpdatedUserRow({ row })), Scene.expect(dnd).not.toBeChecked(), Scene.click(dnd), @@ -50,7 +50,7 @@ describe("do not disturb", () => { test("a failed save toasts and flips the switch back", () => { Scene.scene( pageScene(update, view, shared), - Scene.given(init(undefined, shared).model), + Scene.given(init().model), Scene.expect(showQuietHours).toBeChecked(), Scene.click(showQuietHours), Scene.expect(showQuietHours).not.toBeChecked(), @@ -69,7 +69,7 @@ describe("sounds", () => { test("turning sounds off asks the root to store them", () => { Scene.scene( pageScene(update, view, shared), - Scene.given(init(undefined, shared).model), + Scene.given(init().model), Scene.click(Scene.label("Enable notification sounds")), Scene.expectOutMessage( PageOutMessage.RequestedSoundSettings({ @@ -83,7 +83,7 @@ describe("sounds", () => { const muted = makeShared({ soundSettings: { ...shared.soundSettings, enabled: false } }) Scene.scene( pageScene(update, view, muted), - Scene.given(init(undefined, muted).model), + Scene.given(init().model), Scene.expect(Scene.role("button", { name: "Test sound" })).toBeDisabled(), Scene.expect(Scene.role("button", { name: "Test notification" })).toBeEnabled(), ) @@ -93,7 +93,7 @@ describe("sounds", () => { const note = Scene.text("Native notifications unavailable (desktop app only)") Scene.scene( pageScene(update, view, shared), - Scene.given(init(undefined, shared).model), + Scene.given(init().model), Scene.click(Scene.role("button", { name: "Test notification" })), Scene.Command.expectExact(SendTestNotification), Scene.Command.resolve(SendTestNotification, Message.CompletedTestNotification({ isSent: false })), diff --git a/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts b/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts index edc89c61a..cb4a2e486 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts @@ -10,7 +10,8 @@ import { UserRow } from "../user" import { ExpireNotificationStatus, SendTestNotification, UpdateUserSettings } from "./command" import { Message } from "./message" import { settingsOf } from "./model" -import { init, sharedChanged, update } from "./update" +import * as Slider from "../../../ui/slider" +import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" /** Update-loop tests for notification preferences: optimistic user settings and the sound settings. */ @@ -47,7 +48,7 @@ describe("notification preferences", () => { test("the synced quiet hours drive the time fields", () => { story( pageUpdate, - given(init(undefined, shared).model), + given(init().model), message(Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })), model((current) => { expect(current.quietHoursStart.committed).toBe("21:30:00") @@ -59,7 +60,7 @@ describe("notification preferences", () => { test("do not disturb is written optimistically with the other settings kept", () => { story( pageUpdate, - given(init(undefined, shared).model), + given(init().model), message(Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })), message(Message.ToggledDoNotDisturb({ isSelected: true })), model((current) => @@ -82,9 +83,8 @@ describe("notification preferences", () => { test("turning sounds off asks the root to store them", () => { story( pageUpdate, - given(init(undefined, shared).model), + given(init().model), message(Message.ToggledSounds({ isSelected: false })), - model((current) => expect(current.volume.isDisabled).toBe(true)), expectOutMessage( PageOutMessage.RequestedSoundSettings({ settings: { ...shared.soundSettings, enabled: false }, @@ -93,13 +93,24 @@ describe("notification preferences", () => { ) }) - test("the volume slider follows Shared.soundSettings", () => { - const next = sharedChanged(init(undefined, shared).model, { - ...shared, - soundSettings: { ...shared.soundSettings, volume: 0.8, enabled: false }, - }).model - expect(next.volume.values).toEqual([0.8]) - expect(next.volume.isDisabled).toBe(true) + test("the volume slider reports a step from Shared.soundSettings and the root stores it", () => { + story( + pageUpdate, + given(init().model), + message( + Message.GotVolumeMessage({ + message: Slider.Message.PressedKeyboardNavigation({ + direction: "StepIncrement", + value: shared.soundSettings.volume, + }), + }), + ), + expectOutMessage( + PageOutMessage.RequestedSoundSettings({ + settings: { ...shared.soundSettings, volume: Slider.snapAndClamp(shared.soundSettings.volume + 0.1, 0, 1, 0.1) }, + }), + ), + ) }) }) @@ -107,7 +118,7 @@ describe("user settings writes", () => { test("a saved setting stays optimistic until the synced row replaces it", () => { story( pageUpdate, - given(init(undefined, shared).model), + given(init().model), message(Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })), message(Message.ToggledShowQuietHours({ isSelected: true })), Command.resolve(UpdateUserSettings, Message.SucceededUpdateUserSettings()), @@ -129,7 +140,7 @@ describe("user settings writes", () => { }) const synced = () => - pageUpdate(init(undefined, shared).model, Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })).model + pageUpdate(init().model, Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })).model const settingsWith = (patch: object) => Schema.decodeUnknownSync(User.UserSettingsSchema)({ ...row.settings, ...patch }) const writeOf = (result: ReturnType<typeof pageUpdate>) => result.commands?.map((command) => ({ name: command.name, args: command.args })) @@ -175,7 +186,7 @@ describe("user settings writes", () => { const signedOut = { ...shared, currentUser: null } story( (current: Parameters<typeof update>[0], next: Message) => update(current, next, signedOut), - given(init(undefined, signedOut).model), + given(init().model), message(Message.ToggledDoNotDisturb({ isSelected: true })), Command.expectNone(), model((current) => expect(current.optimisticSettings).toBeNull()), @@ -187,7 +198,7 @@ describe("test notification", () => { test("a sent notification shows the confirmation, then expires back to idle", () => { story( pageUpdate, - given(init(undefined, shared).model), + given(init().model), message(Message.ClickedTestNotification()), Command.expectExact(SendTestNotification()), Command.resolve(SendTestNotification, Message.CompletedTestNotification({ isSent: true })), @@ -199,7 +210,7 @@ describe("test notification", () => { }) test("an older expiry timer leaves a newer result showing", () => { - const first = pageUpdate(init(undefined, shared).model, Message.CompletedTestNotification({ isSent: true })).model + const first = pageUpdate(init().model, Message.CompletedTestNotification({ isSent: true })).model const second = pageUpdate(first, Message.CompletedTestNotification({ isSent: false })).model const expiredFirst = pageUpdate(second, Message.ExpiredNotificationStatus({ version: 1 })).model expect(expiredFirst.notificationStatus).toBe("unavailable") diff --git a/apps/web-foldkit/src/page/my-settings/notifications/update.ts b/apps/web-foldkit/src/page/my-settings/notifications/update.ts index cc0a8ad9f..799cb5d71 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/update.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/update.ts @@ -58,20 +58,11 @@ const syncQuietHours = (model: Model): Model => { }) } -/** The slider follows the stored volume and is disabled with the sounds. */ -const syncVolume = (model: Model, sound: SoundSettings): Model => - modifyFields(model, { - volume: (volume) => - volume.values[0] === sound.volume && volume.isDisabled === !sound.enabled - ? volume - : { ...volume, values: [sound.volume], isDisabled: !sound.enabled }, - }) - /** `updateSettings(patch)`: the root stores it and informs the page (`sharedChanged`). */ const withSound = (model: Model, shared: Shared, patch: Partial<SoundSettings>): Return => { const settings = { ...shared.soundSettings, ...patch } return { - model: syncVolume(model, settings), + model, outMessage: PageOutMessage.RequestedSoundSettings({ settings }), } } @@ -127,50 +118,35 @@ const foldQuietHours = (field: QuietHoursField) => toParentMessage: toQuietHoursMessage(field), }) -const foldVolume = Update.foldChild({ - update: Slider.update, - read: (model: Model) => Option.some(model.volume), - write: (model: Model, volume: Slider.Model): Model => modifyFields(model, { volume: () => volume }), - toParentMessage: toVolumeMessage, -}) +/** The volume lives in `Shared.soundSettings`; the Slider holds only the drag and reports changes. */ +const volumeChanged = (model: Model, shared: Shared, message: Slider.Message): Return => { + const result = Slider.update(model.volume, message) + const next = modifyFields(model, { volume: () => result.model }) + const changed = result.outMessage + return changed === undefined || changed.value === shared.soundSettings.volume + ? { model: next } + : withSound(next, shared, { volume: changed.value }) +} -export const init = (_route: unknown, shared: Shared): Return => ({ - model: syncVolume( - { +export const init = (): Return => ({ + model: { settings: null, optimisticSettings: null, settingsWrite: "Idle", - volume: Slider.init({ - id: "notification-volume", - values: [0.5], - minValue: 0, - maxValue: 1, - step: 0.1, - }), + volume: Slider.init({ id: "notification-volume", min: 0, max: 1, step: 0.1 }), quietHoursStart: Segments.init({ id: "quiet-hours-start", kind: "time", value: DEFAULT_QUIET_START }), quietHoursEnd: Segments.init({ id: "quiet-hours-end", kind: "time", value: DEFAULT_QUIET_END }), notificationStatus: "idle", notificationStatusVersion: 0, interaction: Interaction.init(), - }, - shared.soundSettings, - ), -}) - -export const sharedChanged = (model: Model, shared: Shared): Return => ({ - model: syncVolume(model, shared.soundSettings), + }, }) export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { ToggledSounds: ({ isSelected }) => withSound(model, shared, { enabled: isSelected }), SelectedSound: ({ soundFile }) => withSound(model, shared, { soundFile }), - GotVolumeMessage: ({ message: child }) => { - const result = foldVolume(model, child) - const volume = result.model.volume.values[0] - if (volume === undefined || volume === shared.soundSettings.volume) return result - return { ...withSound(result.model, shared, { volume }), commands: result.commands ?? [] } - }, + GotVolumeMessage: ({ message: child }) => volumeChanged(model, shared, child), ClickedTestSound: () => ({ model, commands: [PlayTestSound()] }), CompletedTestSound: () => ({ model }), ClickedTestNotification: () => ({ model, commands: [SendTestNotification()] }), diff --git a/apps/web-foldkit/src/page/my-settings/notifications/view.ts b/apps/web-foldkit/src/page/my-settings/notifications/view.ts index 7ab2b52d3..61379719b 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/view.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/view.ts @@ -5,7 +5,7 @@ import { ariaRadioGroup } from "../../../ui/aria-radio" import type * as Interaction from "../../../ui/aria/interaction" import { button } from "../../../ui/button" import { dateField } from "../../../ui/date-field" -import { slider } from "../../../ui/slider" +import * as Slider from "../../../ui/slider" import { switchControl } from "../../../ui/switch" import type { PageViewInputs } from "../../contract" import { divider, pageHeader, settingsRow } from "../shared" @@ -20,6 +20,8 @@ const soundOptions: ReadonlyArray<{ value: SoundFile; label: string; description { value: "notification03", label: "Sound 2", description: "Modern alert" }, ] +const volumeText = (volume: number) => `${Math.round(volume * 100)}%` + const isSoundFile = (value: string): value is SoundFile => value === "notification01" || value === "notification03" @@ -122,23 +124,19 @@ const soundSettings = ( h.div( [h.Class("flex flex-col gap-2")], [ - slider( - h, - { model: model.volume, toParentMessage: toVolumeMessage, interaction: wiring }, - (parts) => [ - h.div( - [h.Class("flex items-center justify-between")], - [ - h.div([h.Class("font-medium text-sm")], ["Volume"]), - parts.output({ - className: "text-muted-fg text-sm", - format: (values) => `${Math.round((values[0] ?? 0) * 100)}%`, - }), - ], - ), - parts.track(), - ], - ), + h.submodel({ + slotId: "volume", + model: model.volume, + view: Slider.view, + viewInputs: { + value: sound.volume, + isDisabled: !enabled, + formatValue: volumeText, + toView: (attributes) => + Slider.layout(h, attributes, { label: "Volume", valueText: volumeText(sound.volume) }), + }, + toParentMessage: toVolumeMessage, + }), ], ), h.div( diff --git a/apps/web-foldkit/src/ui/slider.scene.test.ts b/apps/web-foldkit/src/ui/slider.scene.test.ts index efe7084f6..fe300c94b 100644 --- a/apps/web-foldkit/src/ui/slider.scene.test.ts +++ b/apps/web-foldkit/src/ui/slider.scene.test.ts @@ -1,115 +1,94 @@ // @vitest-environment jsdom -import { Function } from "effect" +import { Schema } from "effect" +import type { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" import * as Scene from "foldkit/scene" -import type { HtmlBuilder } from "foldkit/html" +import { modifyFields } from "foldkit/struct" import { describe, test } from "vitest" -import { init, type Message, type Model, slider, update } from "./slider" +import * as Slider from "./slider" -/** Slider through the view: thumb input semantics, labelling, keys and disabled state. */ +/** The styled `@foldkit/ui` Slider in a parent that owns the value. */ -const labelledView = (model: Model, h: HtmlBuilder<Message>) => - slider(h, { model, toParentMessage: Function.identity }, (parts) => [ - parts.label(["Volume"]), - parts.output(), - parts.track(), - ]) +const Model = Schema.Struct({ slider: Slider.Model, value: Schema.Number, isDisabled: Schema.Boolean }) +type Model = typeof Model.Type -const ariaLabelledView = (model: Model, h: HtmlBuilder<Message>) => - slider(h, { model, toParentMessage: Function.identity, ariaLabel: "Vertical" }, (parts) => [ - parts.track(), - ]) +const Message = defineMessageUnion({ GotSliderMessage: { message: Slider.Message } }) +type Message = typeof Message.Type -const labelled = { update, view: labelledView } -const thumb = Scene.role("slider", { name: "Volume" }) - -describe("slider scene", () => { - test("renders a labelled group with a range thumb that reports its bounds and value", () => { - Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [40], minValue: 10, maxValue: 90, step: 5 })), - Scene.expect(Scene.role("group", { name: "Volume" })).toHaveAttr( - "data-orientation", - "horizontal", - ), - Scene.expect(thumb).toHaveValue("40"), - Scene.expect(thumb).toHaveAttr("aria-valuetext", "40"), - Scene.expect(thumb).toHaveAttr("min", "10"), - Scene.expect(thumb).toHaveAttr("max", "90"), - Scene.expect(thumb).toHaveAttr("step", "5"), - Scene.expect(thumb).toHaveAttr("aria-orientation", "horizontal"), - Scene.expect(thumb).toHaveId("volume-label-0"), - Scene.expect(Scene.role("status")).toHaveText("40"), - ) +const update = (model: Model, message: Message): Update.Return<Model, Message> => + Message.match<Update.Return<Model, Message>>(message, { + GotSliderMessage: ({ message: child }) => { + const result = Slider.update(model.slider, child) + const changed = result.outMessage + return { + model: modifyFields(model, { + slider: () => result.model, + value: (value) => (changed === undefined ? value : changed.value), + }), + } + }, }) - test("PageUp on the thumb raises the value shown in the output", () => { - Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [40] })), - Scene.keydown(thumb, "PageUp"), - Scene.expect(thumb).toHaveValue("50"), - Scene.expect(thumb).toHaveAttr("aria-valuetext", "50"), - Scene.expect(Scene.role("status")).toHaveText("50"), - ) - }) +const percent = (value: number) => `${Math.round(value * 100)}%` - test("End and Home move the thumb to the bounds", () => { - Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [40] })), - Scene.keydown(thumb, "End"), - Scene.expect(thumb).toHaveValue("100"), - Scene.keydown(thumb, "Home"), - Scene.expect(thumb).toHaveValue("0"), - ) - }) +const view = (model: Model, h: HtmlBuilder<Message>): Html => + h.main( + [], + [ + h.submodel({ + slotId: "volume", + model: model.slider, + view: Slider.view, + viewInputs: { + value: model.value, + isDisabled: model.isDisabled, + formatValue: percent, + toView: (attributes) => + Slider.layout(h, attributes, { label: "Volume", valueText: percent(model.value) }), + }, + toParentMessage: (message) => Message.GotSliderMessage({ message }), + }), + ], + ) - test("arrow keys are left to the native range input", () => { - Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [40] })), - Scene.keydown(thumb, "ArrowRight"), - Scene.expectIgnored(), - Scene.type(thumb, "41"), - Scene.expect(thumb).toHaveValue("41"), - ) - }) +const init = (overrides: Partial<Model> = {}): Model => ({ + slider: Slider.init({ id: "volume", min: 0, max: 1, step: 0.1 }), + value: 0.5, + isDisabled: false, + ...overrides, +}) - test("a disabled slider disables its thumb and marks every part disabled", () => { +const thumb = Scene.role("slider", { name: "Volume" }) + +describe("slider scene", () => { + test("the thumb is named by the label and reports the parent's value", () => { Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [60], isDisabled: true })), - Scene.expect(thumb).toBeDisabled(), - Scene.expect(Scene.role("group", { name: "Volume" })).toHaveAttr("data-disabled", "true"), - Scene.expect(Scene.selector("#volume-track")).toHaveAttr("data-disabled", "true"), - Scene.expect(Scene.selector("#volume-track")).not.toHaveHandler("pointerdown"), + { update, view }, + Scene.given(init()), + Scene.expect(thumb).toHaveAttr("aria-valuenow", "0.5"), + Scene.expect(thumb).toHaveAttr("aria-valuetext", "50%"), + Scene.expect(Scene.text("50%")).toExist(), ) }) - test("an aria-label names the group and the thumb is labelled by the group", () => { + test("arrow keys change the value the parent stores", () => { Scene.scene( - { update, view: ariaLabelledView }, - Scene.given(init({ id: "vertical", values: [30], orientation: "vertical" })), - Scene.expect(Scene.role("group", { name: "Vertical" })).toHaveAttr( - "data-orientation", - "vertical", - ), - Scene.expect(Scene.role("slider")).toHaveAttr("aria-orientation", "vertical"), - Scene.expect(Scene.role("slider")).toHaveAttr("aria-labelledby", "vertical"), + { update, view }, + Scene.given(init()), + Scene.keydown(thumb, "ArrowRight"), + Scene.expect(thumb).toHaveAttr("aria-valuenow", "0.6"), + Scene.keydown(thumb, "Home"), + Scene.expect(thumb).toHaveAttr("aria-valuenow", "0"), ) }) -}) -describe("slider scene: pointer", () => { - // Finding: the track's pointerdown handler reads `document.getElementById` in the view, so in - // Scene (no live DOM) a primary-button press produces no Message. - test("a track press outside a live document produces no Message", () => { + test("a disabled slider ignores keys and is marked disabled", () => { Scene.scene( - labelled, - Scene.given(init({ id: "volume", values: [40] })), - Scene.pointerDown(Scene.selector("#volume-track"), { clientX: 80 }), - Scene.expectIgnored(), - Scene.expect(thumb).toHaveValue("40"), + { update, view }, + Scene.given(init({ isDisabled: true })), + Scene.expect(thumb).toHaveAttr("aria-disabled", "true"), + Scene.expect(thumb).not.toHaveHandler("keydown"), ) }) }) diff --git a/apps/web-foldkit/src/ui/slider.story.test.ts b/apps/web-foldkit/src/ui/slider.story.test.ts deleted file mode 100644 index 449a9d101..000000000 --- a/apps/web-foldkit/src/ui/slider.story.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Command, expectNoOutMessage, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { FocusSliderThumb, init, Message, update } from "./slider" - -/** React Aria Slider state: native input changes, Page/Home/End keys, track press and drag. */ - -const volume = init({ id: "volume", values: [40] }) -const price = init({ id: "price", values: [20, 70] }) -const key = (value: string, index = 0) => message(Message.PressedThumbKey({ index, key: value })) -const input = (value: string, index = 0) => message(Message.ChangedThumbInput({ index, value })) - -describe("slider story", () => { - test("PageUp and PageDown move the thumb by a tenth of the range", () => { - story( - update, - given(volume), - key("PageUp"), - Command.expectNone(), - model((next) => expect(next.values).toEqual([50])), - key("PageDown"), - key("PageDown"), - model((next) => expect(next.values).toEqual([30])), - ) - }) - - test("Home and End jump the thumb to the minimum and maximum", () => { - story( - update, - given(init({ id: "volume", values: [40], minValue: 10, maxValue: 90 })), - key("End"), - model((next) => expect(next.values).toEqual([90])), - key("Home"), - model((next) => expect(next.values).toEqual([10])), - ) - }) - - test("PageUp never moves less than one step on a narrow range", () => { - story( - update, - given(init({ id: "rating", values: [2], minValue: 0, maxValue: 5, step: 1 })), - key("PageUp"), - model((next) => expect(next.values).toEqual([3])), - ) - }) - - test("arrow key input from the native range snaps to the step", () => { - story( - update, - given(init({ id: "volume", values: [40], step: 5 })), - input("42"), - model((next) => expect(next.values).toEqual([40])), - input("43"), - model((next) => expect(next.values).toEqual([45])), - ) - }) - - test("values beyond the range are clamped to the minimum and maximum", () => { - story( - update, - given(volume), - input("150"), - model((next) => expect(next.values).toEqual([100])), - input("-20"), - model((next) => expect(next.values).toEqual([0])), - key("PageDown"), - model((next) => expect(next.values).toEqual([0])), - ) - }) - - test("a range thumb cannot pass its neighbour", () => { - story( - update, - given(price), - input("90", 0), - model((next) => expect(next.values).toEqual([70, 70])), - key("Home", 1), - model((next) => expect(next.values).toEqual([70, 70])), - ) - story( - update, - given(price), - key("End", 0), - key("Home", 1), - model((next) => expect(next.values).toEqual([70, 70])), - ) - }) - - // T9: snapping rounds to the step's precision, as React Aria's roundToStepPrecision does. - test("a decimal step lands exactly on the step value", () => { - story( - update, - given(init({ id: "opacity", values: [0], minValue: 0, maxValue: 1, step: 0.1 })), - input("0.3"), - model((next) => expect(next.values).toEqual([0.3])), - input("0.7"), - model((next) => expect(next.values).toEqual([0.7])), - ) - }) -}) - -describe("slider story: pointer", () => { - test("pressing the track moves the closest thumb, starts a drag and focuses that thumb", () => { - story( - update, - given(price), - message(Message.PressedTrack({ value: 60, inputPrefix: "price-label" })), - Command.expectExact(FocusSliderThumb({ inputId: "price-label-1" })), - Command.resolve(FocusSliderThumb, Message.CompletedFocusThumb()), - model((next) => { - expect(next.values).toEqual([20, 60]) - expect(next.dragging).toBe(1) - }), - ) - }) - - test("pressing the track below the first thumb picks the first thumb", () => { - story( - update, - given(price), - message(Message.PressedTrack({ value: 5.4, inputPrefix: "price-label" })), - Command.expectExact(FocusSliderThumb({ inputId: "price-label-0" })), - Command.resolve(FocusSliderThumb, Message.CompletedFocusThumb()), - model((next) => expect(next.values).toEqual([5, 70])), - ) - }) - - test("dragging moves the pressed thumb until the pointer is released", () => { - story( - update, - given(volume), - message(Message.PressedTrack({ value: 10, inputPrefix: "volume-label" })), - Command.resolve(FocusSliderThumb, Message.CompletedFocusThumb()), - message(Message.MovedDragPointer({ value: 72.6 })), - model((next) => expect(next.values).toEqual([73])), - message(Message.ReleasedDragPointer()), - model((next) => expect(next.dragging).toBeNull()), - message(Message.MovedDragPointer({ value: 20 })), - model((next) => expect(next.values).toEqual([73])), - ) - }) - - test("value changes are not reported to the parent as an OutMessage", () => { - story(update, given(volume), key("End"), expectNoOutMessage()) - }) -}) diff --git a/apps/web-foldkit/src/ui/slider.ts b/apps/web-foldkit/src/ui/slider.ts index e3ea7524a..3672200cb 100644 --- a/apps/web-foldkit/src/ui/slider.ts +++ b/apps/web-foldkit/src/ui/slider.ts @@ -1,448 +1,48 @@ -import { Effect, Number, Option, Schema, Stream } from "effect" -import { Command, Subscription, type Update } from "foldkit" -import * as Dom from "foldkit/dom" +import { Slider } from "@foldkit/ui" import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" import { twMerge } from "tailwind-merge" import { - sliderFillStyle, sliderFillStyles, sliderOutputStyles, sliderStyles, sliderThumbStyles, sliderTrackStyles, } from "~/components/ui/slider.styles" -import * as Interaction from "./aria/interaction" -import * as Field from "./field" /** - * Port of `components/ui/slider.tsx` (React Aria Slider). Values and the dragged thumb live in this - * Submodel; the view renders in the parent's builder. Like the legacy SliderTrack default, the track - * renders one thumb (index 0) even when the value is a range. + * The `@foldkit/ui` Slider, styled. The parent owns the value (it passes it to `Slider.view` through + * `viewInputs.value` and stores `ChangedValue`); the Submodel only holds the range and the drag. + * `layout` is the `toView` that draws the label, the value and the track with the kit's classes. */ - -// MODEL - -export const Orientation = Schema.Literals(["horizontal", "vertical"]) -export type Orientation = typeof Orientation.Type - -export const Model = Schema.Struct({ - id: Schema.String, - values: Schema.Array(Schema.Number), - minValue: Schema.Number, - maxValue: Schema.Number, - step: Schema.Number, - orientation: Orientation, - isDisabled: Schema.Boolean, - dragging: Schema.NullOr(Schema.Number), -}) -export type Model = typeof Model.Type - -export const init = (options: { - readonly id: string - readonly values: ReadonlyArray<number> - readonly minValue?: number - readonly maxValue?: number - readonly step?: number - readonly orientation?: Orientation - readonly isDisabled?: boolean -}): Model => ({ - id: options.id, - values: options.values, - minValue: options.minValue ?? 0, - maxValue: options.maxValue ?? 100, - step: options.step ?? 1, - orientation: options.orientation ?? "horizontal", - isDisabled: options.isDisabled ?? false, - dragging: null, -}) - -// MESSAGE - -export const Message = defineMessageUnion({ - ChangedThumbInput: { index: Schema.Number, value: Schema.String }, - PressedThumbKey: { index: Schema.Number, key: Schema.String }, - PressedTrack: { value: Schema.Number, inputPrefix: Schema.String }, - MovedDragPointer: { value: Schema.Number }, - ReleasedDragPointer: {}, - CompletedFocusThumb: {}, -}) -export type Message = typeof Message.Type - -// UPDATE - -export const thumbMin = (model: Model, index: number) => model.values[index - 1] ?? model.minValue -export const thumbMax = (model: Model, index: number) => model.values[index + 1] ?? model.maxValue -const pageSize = (model: Model) => Math.max((model.maxValue - model.minValue) / 10, model.step) - -/** @react-stately/utils roundToStepPrecision: drop float noise below the step's precision. */ -const roundToStepPrecision = (value: number, step: number) => { - const stepString = step.toString() - const eIndex = stepString.toLowerCase().indexOf("e-") - const pointIndex = stepString.indexOf(".") - const precision = - eIndex > 0 - ? Math.abs(Math.floor(Math.log10(Math.abs(step)))) + eIndex - : pointIndex >= 0 - ? stepString.length - pointIndex - : 0 - const pow = 10 ** precision - return precision > 0 ? Math.round(value * pow) / pow : value -} - -/** useSliderState: clamp to the thumb's neighbours and snap to the step. */ -const setThumbValue = (model: Model, index: number, value: number): Model => { - const snapped = roundToStepPrecision( - Math.round((value - model.minValue) / model.step) * model.step + model.minValue, - model.step, - ) - const next = Number.clamp(snapped, { minimum: thumbMin(model, index), maximum: thumbMax(model, index) }) - return modifyFields(model, { - values: (values) => values.map((current, i) => (i === index ? next : current)), - }) -} - -/** useSlider's onDownTrack: the closest thumb moves (ties go to the later one when it is ahead). */ -const closestThumb = (model: Model, value: number) => { - const index = model.values.findIndex((thumbValue) => value - thumbValue < 0) - if (index === 0) return 0 - if (index === -1) return model.values.length - 1 - const before = model.values[index - 1] ?? 0 - const after = model.values[index] ?? 0 - return Math.abs(before - value) < Math.abs(after - value) ? index - 1 : index -} - -// COMMAND - -/** useSliderThumb focuses the thumb input when a track press selects it. */ -export const FocusSliderThumb = Command.define("FocusSliderThumb", { - args: { inputId: Schema.String }, - messages: [Message.CompletedFocusThumb], - execute: ({ inputId }) => - Dom.focus(`#${CSS.escape(inputId)}`, { preventScroll: true }).pipe( - Effect.ignore, - Effect.as(Message.CompletedFocusThumb()), - ), -}) - -export const update = (model: Model, message: Message): Update.Return<Model, Message> => - Message.match<Update.Return<Model, Message>>(message, { - ChangedThumbInput: ({ index, value }) => ({ - model: setThumbValue(model, index, globalThis.Number.parseFloat(value)), - }), - PressedThumbKey: ({ index, key }) => { - const current = model.values[index] ?? model.minValue - const target = - key === "PageUp" - ? current + pageSize(model) - : key === "PageDown" - ? current - pageSize(model) - : key === "Home" - ? thumbMin(model, index) - : thumbMax(model, index) - return { model: setThumbValue(model, index, target) } - }, - PressedTrack: ({ value, inputPrefix }) => { - const index = closestThumb(model, value) - return { - model: modifyFields(setThumbValue(model, index, value), { dragging: () => index }), - commands: [FocusSliderThumb({ inputId: `${inputPrefix}-${index}` })], - } - }, - MovedDragPointer: ({ value }) => ({ - model: model.dragging === null ? model : setThumbValue(model, model.dragging, value), - }), - ReleasedDragPointer: () => ({ model: modifyFields(model, { dragging: () => null }) }), - CompletedFocusThumb: () => ({ model }), - }) - -// SUBSCRIPTION - -const trackOf = (id: string) => Option.fromNullishOr(document.getElementById(`${id}-track`)) - -/** Pointer position to value along the track, read from the live track element. */ -interface Axis { - readonly orientation: Orientation - readonly minValue: number - readonly maxValue: number -} - -export const valueFromPointer = (model: Axis, track: Element, clientX: number, clientY: number) => { - const rect = track.getBoundingClientRect() - const fraction = - model.orientation === "horizontal" - ? (clientX - rect.left) / (rect.width || 1) - : (rect.bottom - clientY) / (rect.height || 1) - return ( - model.minValue + Number.clamp(fraction, { minimum: 0, maximum: 1 }) * (model.maxValue - model.minValue) - ) -} - -export const subscriptions = Subscription.make<Model, Message>()((entry) => ({ - drag: entry( - { - isDragging: Schema.Boolean, - id: Schema.String, - orientation: Orientation, - minValue: Schema.Number, - maxValue: Schema.Number, - }, - { - modelToDependencies: (model) => ({ - isDragging: model.dragging !== null, - id: model.id, - orientation: model.orientation, - minValue: model.minValue, - maxValue: model.maxValue, - }), - dependenciesToStream: (dependencies) => - dependencies.isDragging - ? Stream.merge( - Dom.streamFromEventFilterMap({ - target: document, - type: "pointermove", - filterMapEvent: (event) => - Option.map(trackOf(dependencies.id), (track) => - Message.MovedDragPointer({ - value: valueFromPointer( - dependencies, - track, - event.clientX, - event.clientY, - ), - }), - ), - }), - Dom.streamFromEvent({ - target: document, - type: "pointerup", - mapEvent: () => Message.ReleasedDragPointer(), - }), - ) - : Stream.empty, - }, - ), -})) - -// VIEW - -export interface SliderViewOptions<ParentMessage> { - readonly model: Model - readonly toParentMessage: (message: Message) => ParentMessage - /** Without a visible Label, React Aria labels the group with this and the thumb by the group. */ - readonly ariaLabel?: string - readonly className?: string - readonly interaction?: Interaction.Wiring<ParentMessage> -} - -export interface SliderParts { - readonly label: (children: Array<Html | string>) => Html - /** `className` is merged as `cx(sliderOutputStyles, className)`; `format` is the render-prop text. */ - readonly output: (options?: { - readonly className?: string - readonly format?: (values: ReadonlyArray<number>) => string - }) => Html - readonly track: () => Html -} - -const percentOf = (model: Model, value: number) => - ((value - model.minValue) / (model.maxValue - model.minValue)) * 100 - -export const slider = <ParentMessage>( - h: HtmlBuilder<ParentMessage>, - options: SliderViewOptions<ParentMessage>, - render: (parts: SliderParts) => Array<Html>, -): Html => { - const model = options.model - const send = options.toParentMessage - const interaction = options.interaction - const ids = { group: model.id, label: `${model.id}-label`, track: `${model.id}-track` } - // React Aria names thumb inputs after the label id (or the group id without a label). - const labelledBy = options.ariaLabel === undefined ? ids.label : ids.group - const thumbId = (index: number) => `${labelledBy}-${index}` - // Set when `render` draws the Label. Without one React Aria leaves the group unnamed and names - // the thumb after the group. `parts.label` comes before `parts.track` in every render. - let hasLabel = false - const thumbLabelledBy = () => (options.ariaLabel === undefined && hasLabel ? ids.label : ids.group) - const orientationAttribute = h.DataAttribute("orientation", model.orientation) - const disabledAttributes = model.isDisabled ? [h.DataAttribute("disabled", "true")] : [] - const target = (part: string) => `${model.id}-${part}` - - const thumb = (index: number) => { - const value = model.values[index] ?? model.minValue - const state = interaction - ? Interaction.stateOf(interaction.model, target(`thumb-${index}`)) - : Interaction.idleState - const percent = percentOf(model, value) - const position = model.orientation === "horizontal" ? `left: ${percent}%;` : `top: ${100 - percent}%;` - return h.div( - [ - h.Class(twMerge(twMerge(sliderThumbStyles))), - h.Attribute( - "style", - `position: absolute; ${position} transform: translate(-50%, -50%); touch-action: none;`, - ), - ...disabledAttributes, - ...(model.dragging === index ? [h.DataAttribute("dragging", "true")] : []), - ...(interaction - ? [ - ...Interaction.handlers(h, interaction, target(`thumb-${index}`), { - isHoverDisabled: model.isDisabled, - isPressDisabled: true, - isFocusDisabled: true, - }), - ...Interaction.stateAttributes(h, state), - ] - : []), - ], - [ - h.div( - [ - h.Attribute( - "style", - "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;", - ), - ], - [ - h.input([ - h.Id(thumbId(index)), - h.Type("range"), - h.Attribute("aria-describedby", ""), - h.Attribute("aria-details", ""), - h.AriaLabelledBy(thumbLabelledBy()), - h.AriaOrientation(model.orientation), - h.Attribute("aria-valuetext", String(value)), - h.Min(String(thumbMin(model, index))), - h.Max(String(thumbMax(model, index))), - h.Step(String(model.step)), - h.Attribute("style", ""), - h.Tabindex(0), - ...(model.isDisabled ? [h.Disabled(true)] : []), - h.Value(String(value)), - h.OnInput((next) => send(Message.ChangedThumbInput({ index, value: next }))), - h.OnKeyDownPreventDefault((key) => - /^(PageUp|PageDown|Home|End)$/.test(key) - ? Option.some(send(Message.PressedThumbKey({ index, key }))) - : Option.none(), - ), - ...(interaction - ? Interaction.handlers(h, interaction, target(`thumb-${index}`), { - isHoverDisabled: true, - isPressDisabled: true, - isFocusDisabled: model.isDisabled, - }) - : []), - ]), - ], - ), - ], - ) - } - - const parts: SliderParts = { - label: (children) => { - hasLabel = true - return Field.label(h, { id: ids.label }, children) - }, - output: (outputOptions = {}) => - h.output( +export const { Model, Message, OutMessage, init, update, subscriptions, snapAndClamp, view } = Slider +export type Model = Slider.Model +export type Message = Slider.Message +export type OutMessage = Slider.OutMessage + +export const layout = <M>( + h: HtmlBuilder<M>, + attributes: Slider.SliderAttributes, + options: { readonly label: string; readonly valueText: string; readonly className?: string }, +): Html => + h.div( + [h.Class(twMerge("flex flex-col gap-2", options.className)), h.DataAttribute("slot", "control")], + [ + h.div( + [h.Class("flex items-center justify-between")], [ - h.Class(twMerge(twMerge(sliderOutputStyles), outputOptions.className)), - h.AriaLive("off"), - orientationAttribute, - ...disabledAttributes, - h.For(model.values.map((_, index) => thumbId(index)).join(" ")), + h.label([...attributes.label, h.Class("font-medium text-sm")], [options.label]), + h.span([h.Class(twMerge(sliderOutputStyles, "text-muted-fg text-sm"))], [options.valueText]), ], - [outputOptions.format ? outputOptions.format(model.values) : String(model.values[0] ?? "")], ), - track: () => { - const state = interaction - ? Interaction.stateOf(interaction.model, target("track")) - : Interaction.idleState - return h.div( - [ - h.Id(ids.track), - h.Class(twMerge(twMerge(...sliderTrackStyles))), - orientationAttribute, - ...disabledAttributes, - h.Attribute("style", "position: relative; touch-action: none;"), - ...(interaction - ? [ - ...Interaction.handlers(h, interaction, target("track"), { - isPressDisabled: true, - isFocusDisabled: true, - }), - ...Interaction.stateAttributes(h, state), - ] - : []), - ...(model.isDisabled - ? [] - : [ - h.OnPointerDown( - ( - _pointerType, - button, - _screenX, - _screenY, - _timeStamp, - clientX, - clientY, - ) => - button === 0 - ? Option.map(trackOf(model.id), (track) => - send( - Message.PressedTrack({ - value: valueFromPointer( - model, - track, - clientX, - clientY, - ), - inputPrefix: labelledBy, - }), - ), - ) - : Option.none(), - ), - ]), - ], + h.div( + [...attributes.root, h.Class(twMerge(...sliderStyles))], [ - h.div([ - h.Class(twMerge(sliderFillStyles)), - h.Attribute( - "style", - Object.entries( - sliderFillStyle( - model.orientation, - model.values.map((value) => percentOf(model, value)), - ), - ) - .map(([property, value]) => `${property}: ${value};`) - .join(" "), - ), - ]), - thumb(0), + h.div( + [...attributes.track, h.Class(twMerge(...sliderTrackStyles))], + [h.div([...attributes.filledTrack, h.Class(sliderFillStyles)])], + ), + h.div([...attributes.thumb, h.Class(sliderThumbStyles)]), ], - ) - }, - } - - const children = render(parts) - return h.div( - [ - h.Class(twMerge(twMerge(...sliderStyles), options.className)), - h.DataAttribute("slot", "control"), - orientationAttribute, - h.Role("group"), - h.Id(ids.group), - ...(options.ariaLabel !== undefined - ? [h.AriaLabel(options.ariaLabel)] - : hasLabel - ? [h.AriaLabelledBy(ids.label)] - : []), - ...disabledAttributes, + ), ], - children, ) -} diff --git a/apps/web-foldkit/src/ui/tabs.scene.test.ts b/apps/web-foldkit/src/ui/tabs.scene.test.ts index a05d11b3f..63d4a7f33 100644 --- a/apps/web-foldkit/src/ui/tabs.scene.test.ts +++ b/apps/web-foldkit/src/ui/tabs.scene.test.ts @@ -1,36 +1,81 @@ // @vitest-environment jsdom +import { Schema } from "effect" +import type { Update } from "foldkit" +import * as Command from "foldkit/command" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" import * as Scene from "foldkit/scene" +import { modifyFields } from "foldkit/struct" import { describe, test } from "vitest" -import { FocusTab, init, Message, update, view } from "./tabs" +import * as Tabs from "./tabs" -/** Tabs through the view: roles, selection state, keyboard navigation and disabled tabs. */ +/** The styled `@foldkit/ui` Tabs in a parent that owns the selected tab. */ -const sceneView = Scene.withViewInputs(view, { - listLabel: "Settings sections", - tabs: [ - { key: "general", content: ["General"] }, - { key: "members", content: ["Members"] }, - { key: "billing", content: ["Billing"], isDisabled: true }, - ], - panels: [ - { key: "general", content: ["General settings"] }, - { key: "members", content: ["Member list"] }, - { key: "billing", content: ["Billing details"] }, - ], -}) +const Section = Schema.Literals(["general", "members", "billing"]) +type Section = typeof Section.Type +const SectionTabs = Tabs.create<Section>() + +const Model = Schema.Struct({ tabs: Tabs.Model, section: Section }) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ GotTabsMessage: { message: Tabs.Message } }) +type Message = typeof Message.Type + +const toTabsMessage = (message: Tabs.Message) => Message.GotTabsMessage({ message }) + +const update = (model: Model, message: Message): Update.Return<Model, Message> => + Message.match<Update.Return<Model, Message>>(message, { + GotTabsMessage: ({ message: child }) => { + const result = SectionTabs.update(model.tabs, child) + const selected = result.outMessage + return { + model: modifyFields(model, { + tabs: () => result.model, + section: (section) => (selected === undefined ? section : selected.value), + }), + commands: Command.mapMessages(result.commands ?? [], toTabsMessage), + } + }, + }) -const config = { update, view: sceneView() } +const labels: Record<Section, string> = { general: "General", members: "Members", billing: "Billing" } +const panels: Record<Section, string> = { + general: "General settings", + members: "Member list", + billing: "Billing details", +} + +const view = (model: Model, h: HtmlBuilder<Message>): Html => + h.main( + [], + [ + h.submodel({ + slotId: "tabs", + model: model.tabs, + view: SectionTabs.view, + viewInputs: { + tabs: ["general", "members", "billing"], + selectedValue: model.section, + ariaLabel: "Settings sections", + isTabDisabled: (section) => section === "billing", + toView: (render) => + Tabs.layout(h, render, { tab: (section) => [labels[section]], panel: (section) => [panels[section]] }), + }, + toParentMessage: toTabsMessage, + }), + ], + ) + +const init = (section: Section = "general"): Model => ({ tabs: Tabs.init({ id: "settings" }), section }) const tab = (name: string) => Scene.role("tab", { name }) +const resolveFocus = Scene.Command.resolve(Tabs.FocusTab, Tabs.Message.CompletedFocusTab()) describe("tabs scene", () => { - test("renders a labelled tablist with the selected tab and only its panel", () => { + test("renders a labelled tablist with the parent's tab selected and only its panel", () => { Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "general" })), - Scene.expect(Scene.role("tablist", { name: "Settings sections" })).toHaveAttr( - "aria-orientation", - "horizontal", - ), + { update, view }, + Scene.given(init()), + Scene.expect(Scene.role("tablist", { name: "Settings sections" })).toExist(), Scene.expectAll(Scene.all.role("tab")).toHaveCount(3), Scene.expect(tab("General")).toHaveAttr("aria-selected", "true"), Scene.expect(tab("Members")).toHaveAttr("aria-selected", "false"), @@ -39,55 +84,25 @@ describe("tabs scene", () => { ) }) - test("pressing a tab selects it and swaps the panel", () => { + test("clicking a tab tells the parent, which swaps the panel", () => { Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "general" })), - Scene.pointerDown(tab("Members")), + { update, view }, + Scene.given(init()), + Scene.click(tab("Members")), + resolveFocus, Scene.expect(tab("Members")).toHaveAttr("aria-selected", "true"), - Scene.expect(tab("Members")).toHaveAttr("aria-controls", "settings-tabpanel-members"), Scene.expect(Scene.role("tabpanel", { name: "Members" })).toHaveText("Member list"), ) }) - test("ArrowRight wraps past the disabled tab and focuses the new tab", () => { + test("ArrowRight skips the disabled tab and wraps", () => { Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "members" })), + { update, view }, + Scene.given(init("members")), Scene.keydown(tab("Members"), "ArrowRight"), - Scene.Command.expectExact(FocusTab({ elementId: "settings-tab-general" })), - Scene.Command.resolve(FocusTab, Message.CompletedFocusTab()), - Scene.expect(tab("General")).toHaveAttr("aria-selected", "true"), - ) - }) - - test("a key that is not a navigation key is ignored", () => { - Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "general" })), - Scene.keydown(tab("General"), "a"), - Scene.expectIgnored(), + resolveFocus, Scene.expect(tab("General")).toHaveAttr("aria-selected", "true"), - ) - }) - - test("a disabled tab is marked aria-disabled", () => { - Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "general" })), - Scene.expect(tab("Billing")).toHaveAttr("aria-disabled", "true"), - Scene.expect(tab("Billing")).toHaveAttr("data-disabled", "true"), - ) - }) - - test("vertical tabs navigate with ArrowDown", () => { - Scene.scene( - config, - Scene.given(init({ id: "settings", selectedKey: "general", orientation: "vertical" })), - Scene.expect(Scene.role("tablist")).toHaveAttr("aria-orientation", "vertical"), - Scene.keydown(tab("General"), "ArrowDown"), - Scene.Command.resolve(FocusTab, Message.CompletedFocusTab()), - Scene.expect(tab("Members")).toHaveAttr("aria-selected", "true"), + Scene.expect(tab("Billing")).toBeDisabled(), ) }) }) diff --git a/apps/web-foldkit/src/ui/tabs.story.test.ts b/apps/web-foldkit/src/ui/tabs.story.test.ts deleted file mode 100644 index 5994c251a..000000000 --- a/apps/web-foldkit/src/ui/tabs.story.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Command, given, message, model, story } from "foldkit/story" -import { describe, expect, test } from "vitest" -import { FocusTab, init, Message, update } from "./tabs" - -/** React Aria Tabs with automatic activation: arrows select and focus, wrapping past disabled tabs. */ - -const keys = ["general", "members", "billing", "danger"] -const tabs = init({ id: "settings", selectedKey: "general" }) -const navigate = (direction: "Next" | "Previous" | "First" | "Last", disabledKeys: ReadonlyArray<string> = []) => - message(Message.PressedNavigationKey({ direction, keys, disabledKeys })) - -describe("tabs story", () => { - test("ArrowRight selects the next tab and moves focus to it", () => { - story( - update, - given(tabs), - navigate("Next"), - Command.expectExact(FocusTab({ elementId: "settings-tab-members" })), - Command.resolve(FocusTab, Message.CompletedFocusTab()), - model((next) => { - expect(next.selectedKey).toBe("members") - expect(next.interaction.modality).toBe("Keyboard") - }), - ) - }) - - test("navigation skips disabled tabs", () => { - story( - update, - given(tabs), - navigate("Next", ["members"]), - Command.expectExact(FocusTab({ elementId: "settings-tab-billing" })), - Command.resolve(FocusTab, Message.CompletedFocusTab()), - model((next) => expect(next.selectedKey).toBe("billing")), - ) - }) - - test("Previous from the first tab wraps to the last", () => { - story( - update, - given(tabs), - navigate("Previous"), - Command.resolve(FocusTab, Message.CompletedFocusTab()), - model((next) => expect(next.selectedKey).toBe("danger")), - ) - }) - - test("Home and End jump to the first and last enabled tab", () => { - story( - update, - given(init({ id: "settings", selectedKey: "billing" })), - navigate("First", ["general"]), - Command.resolve(FocusTab, Message.CompletedFocusTab()), - model((next) => expect(next.selectedKey).toBe("members")), - navigate("Last", ["danger"]), - Command.resolve(FocusTab, Message.CompletedFocusTab()), - model((next) => expect(next.selectedKey).toBe("billing")), - ) - }) - - test("navigation with every other tab disabled keeps the selection and moves no focus", () => { - story( - update, - given(tabs), - navigate("Next", ["general", "members", "billing", "danger"]), - Command.expectNone(), - model((next) => expect(next.selectedKey).toBe("general")), - ) - }) - - test("a pointer press selects the tab and switches to pointer modality", () => { - story( - update, - given(tabs), - message(Message.PressedTab({ key: "billing" })), - Command.expectNone(), - model((next) => { - expect(next.selectedKey).toBe("billing") - expect(next.interaction.modality).toBe("Pointer") - }), - ) - }) - - test("hover and focus track one key each and clear only their own key", () => { - story( - update, - given(tabs), - message(Message.HoveredTab({ key: "members" })), - message(Message.FocusedTab({ key: "general" })), - message(Message.UnhoveredTab({ key: "general" })), - model((next) => { - expect(next.interaction.maybeHoveredKey).toEqual(expect.objectContaining({ value: "members" })) - expect(next.interaction.maybeFocusedKey).toEqual(expect.objectContaining({ value: "general" })) - }), - message(Message.BlurredTab({ key: "general" })), - model((next) => expect(next.interaction.maybeFocusedKey._tag).toBe("None")), - ) - }) - - test("focusing the panel marks it focused until it blurs", () => { - story( - update, - given(tabs), - message(Message.FocusedPanel()), - model((next) => expect(next.panelFocus).toBe("Focused")), - message(Message.BlurredPanel()), - model((next) => expect(next.panelFocus).toBe("Unfocused")), - ) - }) -}) diff --git a/apps/web-foldkit/src/ui/tabs.ts b/apps/web-foldkit/src/ui/tabs.ts index dfaac257c..4dae773ff 100644 --- a/apps/web-foldkit/src/ui/tabs.ts +++ b/apps/web-foldkit/src/ui/tabs.ts @@ -1,270 +1,69 @@ -import { Array, Effect, Option, Schema } from "effect" -import type { Update } from "foldkit" -import * as Command from "foldkit/command" -import * as Dom from "foldkit/dom" -import type { Attribute, Html } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" -import { defineView } from "foldkit/submodel" +import { Tabs } from "@foldkit/ui" +import { Array } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { tabsStyles } from "~/components/ui/tabs.styles" -import * as Collection from "./aria/collection" -/** Port of `components/ui/tabs.tsx` (React Aria Tabs, automatic activation). */ - -// MODEL - -export const PanelFocus = Schema.Literals(["Unfocused", "Focused"]) -export type PanelFocus = typeof PanelFocus.Type - -export const Model = Schema.Struct({ - id: Schema.String, - orientation: Collection.Orientation, - selectedKey: Schema.String, - interaction: Collection.Interaction, - panelFocus: PanelFocus, -}) -export type Model = typeof Model.Type - -export const init = (config: { - readonly id: string - readonly selectedKey: string - readonly orientation?: Collection.Orientation -}): Model => ({ - id: config.id, - orientation: config.orientation ?? "horizontal", - selectedKey: config.selectedKey, - interaction: Collection.initInteraction(), - panelFocus: "Unfocused", -}) - -// MESSAGE - -export const Message = defineMessageUnion({ - HoveredTab: { key: Schema.String }, - UnhoveredTab: { key: Schema.String }, - PressedTab: { key: Schema.String }, - FocusedTab: { key: Schema.String }, - BlurredTab: { key: Schema.String }, - PressedNavigationKey: { - direction: Collection.Direction, - keys: Schema.Array(Schema.String), - disabledKeys: Schema.Array(Schema.String), +/** + * The `@foldkit/ui` Tabs, styled. The parent owns the selected tab: it passes it to the view as + * `viewInputs.selectedValue` and stores it on the `Selected` OutMessage. The Submodel holds only the + * roving focus. `layout` is the `toView` that draws the tab list and the active panel with the kit's + * classes; hover and focus rings are native CSS. + */ +export const { Model, Message, OutMessage, init, create, FocusTab } = Tabs +export type Model = Tabs.Model +export type Message = Tabs.Message +export type OutMessage<Value extends string = string> = Tabs.OutMessage<Value> +export type RenderInfo<Value extends string = string> = Tabs.RenderInfo<Value> + +export const layout = <M, Value extends string>( + h: HtmlBuilder<M>, + render: RenderInfo<Value>, + options: { + readonly tab: (value: Value) => ReadonlyArray<Html | string> + readonly panel: (value: Value) => ReadonlyArray<Html | string> + readonly className?: string + readonly listClassName?: string }, - ReleasedKey: {}, - FocusedPanel: {}, - BlurredPanel: {}, - CompletedFocusTab: {}, -}) -export type Message = typeof Message.Type - -// COMMAND - -const tabId = (model: Model, key: string) => `${model.id}-tab-${key}` -const panelId = (model: Model, key: string) => `${model.id}-tabpanel-${key}` - -export const FocusTab = Command.define("FocusTab", { - args: { elementId: Schema.String }, - messages: [Message.CompletedFocusTab], - execute: ({ elementId }) => - Dom.focus(Collection.idSelector(elementId)).pipe( - Effect.ignore, - Effect.as(Message.CompletedFocusTab()), - ), -}) - -// UPDATE - -const withInteraction = ( - model: Model, - next: (interaction: Collection.Interaction) => Collection.Interaction, -) => modifyFields(model, { interaction: next }) - -const withPanelFocus = (model: Model, panelFocus: PanelFocus) => - modifyFields(model, { panelFocus: () => panelFocus }) - -export const update = (model: Model, message: Message) => - Message.match<Update.Return<Model, Message>>(message, { - HoveredTab: ({ key }) => ({ model: withInteraction(model, (it) => Collection.hoverItem(it, key)) }), - UnhoveredTab: ({ key }) => ({ - model: withInteraction(model, (it) => Collection.unhoverItem(it, key)), - }), - PressedTab: ({ key }) => ({ - model: modifyFields( - withInteraction(model, (it) => Collection.useModality(it, "Pointer")), - { - selectedKey: () => key, - }, - ), - }), - FocusedTab: ({ key }) => ({ model: withInteraction(model, (it) => Collection.focusItem(it, key)) }), - BlurredTab: ({ key }) => ({ model: withInteraction(model, (it) => Collection.blurItem(it, key)) }), - PressedNavigationKey: ({ direction, keys, disabledKeys }) => - Option.match( - Collection.moveKey( - keys, - model.selectedKey, - direction, - (key) => disabledKeys.includes(key), - true, - ), - { - onNone: () => ({ model }), - onSome: (key) => ({ - model: modifyFields( - withInteraction(model, (it) => Collection.useModality(it, "Keyboard")), - { selectedKey: () => key }, - ), - commands: [FocusTab({ elementId: tabId(model, key) })], - }), - }, - ), - ReleasedKey: () => ({ - model: withInteraction(model, (it) => Collection.useModality(it, "Keyboard")), - }), - FocusedPanel: () => ({ model: withPanelFocus(model, "Focused") }), - BlurredPanel: () => ({ model: withPanelFocus(model, "Unfocused") }), - CompletedFocusTab: () => ({ model }), - }) - -// VIEW - -export interface TabItem { - readonly key: string - readonly content: ReadonlyArray<Html | string> - readonly isDisabled?: boolean - readonly className?: string -} - -export interface TabPanelItem { - readonly key: string - readonly content: ReadonlyArray<Html | string> - readonly className?: string -} - -export type ViewInputs = Readonly<{ - /** TabList `aria-label`; omitted when undefined, like a TabList without one. */ - listLabel?: string - tabs: ReadonlyArray<TabItem> - panels: ReadonlyArray<TabPanelItem> - /** - * Renders a panel's content when set (instead of `content`). Use it for content with event - * handlers: a top-level function is scoped to the parent, a nested Html handler is not. - */ - renderPanel?: (key: string) => ReadonlyArray<Html | string> - className?: string - listClassName?: string -}> - -export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const { orientation, interaction } = model - const keys = Array.map(viewInputs.tabs, (tab) => tab.key) - const isPanelFocused = model.panelFocus === "Focused" - const isRootFocused = Collection.isWithinFocused(interaction) || isPanelFocused - const isFocusVisible = interaction.modality !== "Pointer" - - const disabledKeys = Array.map( - Array.filter(viewInputs.tabs, (tab) => tab.isDisabled === true), - (tab) => tab.key, - ) - const navigate = (keyboardKey: string) => - Option.map(Collection.directionOfKey(orientation, keyboardKey), (direction) => - Message.PressedNavigationKey({ direction, keys, disabledKeys }), - ) - - const tabView = (tab: TabItem): Html => { - const isSelected = tab.key === model.selectedKey - const isTabDisabled = tab.isDisabled === true - const state = Collection.itemState(interaction, tab.key, isTabDisabled) - const interactive: ReadonlyArray<Attribute<Message>> = isTabDisabled - ? [h.AriaDisabled(true)] - : [ - h.Tabindex(isSelected ? 0 : -1), - h.Attribute("data-react-aria-pressable", "true"), - h.OnMouseEnter(Message.HoveredTab({ key: tab.key })), - h.OnMouseLeave(Message.UnhoveredTab({ key: tab.key })), - h.OnPointerDown(() => Option.some(Message.PressedTab({ key: tab.key }))), - h.OnFocus(Message.FocusedTab({ key: tab.key })), - h.OnBlur(Message.BlurredTab({ key: tab.key })), - h.OnKeyDownPreventDefault(navigate), - h.OnKeyUp(() => Message.ReleasedKey()), - ] - return h.keyed("div")( - tab.key, - [ - h.Class(twMerge(twMerge(...tabsStyles.tab(orientation, false)), tab.className)), - h.Id(tabId(model, tab.key)), - h.Role("tab"), - h.AriaSelected(isSelected), - ...(isSelected ? [h.AriaControls(panelId(model, tab.key))] : []), - h.Attribute("data-collection", model.id), - h.Attribute("data-key", tab.key), - h.Attribute("data-slot", "tab"), - ...Collection.stateAttributes(h, { ...state, isSelected, isDisabled: isTabDisabled }), - ...interactive, - ], - [ - ...tab.content, - ...(isSelected - ? [ - h.div([ - h.Class(twMerge(tabsStyles.selectionIndicator(orientation))), - h.Attribute("data-slot", "selected-indicator"), - ]), - ] - : []), - ], - ) - } - - const panelView = (panel: TabPanelItem): Html => - h.keyed("div")( - panel.key, - [ - h.Class(twMerge(twMerge(tabsStyles.tabPanel), panel.className)), - h.Id(panelId(model, panel.key)), - h.Role("tabpanel"), - h.AriaLabelledBy(tabId(model, panel.key)), - h.Tabindex(0), - h.Attribute("data-slot", "tab-panel"), - ...Collection.stateAttributes(h, { - isFocused: isPanelFocused, - isFocusVisible: isPanelFocused && isFocusVisible, - }), - h.OnFocus(Message.FocusedPanel()), - h.OnBlur(Message.BlurredPanel()), - h.OnKeyUp(() => Message.ReleasedKey()), - ], - [...(viewInputs.renderPanel ? viewInputs.renderPanel(panel.key) : panel.content)], - ) - - return h.div( +): Html => + h.div( [ - h.Class(twMerge(twMerge(...tabsStyles.tabs(orientation)), viewInputs.className)), - h.Attribute("data-orientation", orientation), - ...Collection.stateAttributes(h, { - isFocused: isRootFocused, - isFocusVisible: isRootFocused && isFocusVisible, - }), + h.Class(twMerge(twMerge(...tabsStyles.tabs("horizontal")), options.className)), + h.DataAttribute("orientation", "horizontal"), ], [ h.div( [ - h.Class(twMerge([...tabsStyles.tabList(orientation), viewInputs.listClassName])), - h.Id(model.id), - h.Role("tablist"), - ...(viewInputs.listLabel === undefined ? [] : [h.AriaLabel(viewInputs.listLabel)]), - h.AriaOrientation(orientation), - h.Attribute("data-collection", model.id), - h.Attribute("data-orientation", orientation), - h.Attribute("data-slot", "tab-list"), + ...render.tablist, + h.Class(twMerge([...tabsStyles.tabList("horizontal"), options.listClassName])), + h.DataAttribute("slot", "tab-list"), ], - Array.map(viewInputs.tabs, tabView), + Array.map(render.tabs, (info) => + h.keyed("button")( + info.value, + [...info.tab, h.Class(twMerge(...tabsStyles.tab("horizontal", false))), h.DataAttribute("slot", "tab")], + [ + ...options.tab(info.value), + ...(info.isActive + ? [ + h.div([ + h.Class(twMerge(tabsStyles.selectionIndicator("horizontal"))), + h.DataAttribute("slot", "selected-indicator"), + ]), + ] + : []), + ], + ), + ), ), ...Array.map( - Array.filter(viewInputs.panels, (panel) => panel.key === model.selectedKey), - panelView, + Array.filter(render.tabs, (info) => info.isActive), + (info) => + h.keyed("div")( + info.value, + [...info.panel, h.Class(twMerge(tabsStyles.tabPanel)), h.DataAttribute("slot", "tab-panel")], + [...options.panel(info.value)], + ), ), ], ) -}) From 3c8ef37784bd6cd60d446fe6593f3e07f05c4563 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:02:48 +0200 Subject: [PATCH 13/52] fix(backend): let outbox side effects fail so the outbox retries Every message/reaction outbox handler caught its failures and logged them, so the outbox's retry with backoff never saw a transient Discord, cluster or database outage and the sync was dropped for good. Two DB lookups also fell back to defaults: a failed integration-bot lookup could echo a bot's message back to Discord. - Hazel -> provider deliveries claim their receipt before calling the provider, so a retry was always deduped. On a retryable failure (DB, provider transport, 408/429/5xx) the claim is now released and *ToAllConnections fails with ChatSyncDeliveryRetryableError; 4xx and missing links stay logged-and-skipped. - MessageSideEffectService propagates DB errors and an unreachable cluster. Notifications still go out when Discord fails (the Discord failure is raised last); workflow failures stored under their idempotency key are logged, since a retry would get the same result. - The processor decodes payloads with decodeUnknownEffect instead of throwing a defect that killed the batch. Dispatcher errors are a tagged OutboxLockError; leader-loop / drain / gateway crashes log the Cause instead of String(cause) and let interruption through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/backend/src/lib/error-reporter.test.ts | 4 +- apps/backend/src/routes/integrations.http.ts | 12 +- apps/backend/src/routes/webhooks.http.ts | 6 +- .../backend/src/rpc/handlers/organizations.ts | 17 +- apps/backend/src/rpc/middleware/auth.test.ts | 12 +- apps/backend/src/rpc/middleware/auth.ts | 7 +- apps/backend/src/services/auth.ts | 6 +- .../chat-sync-core-worker.integration.test.ts | 99 ++- .../chat-sync/chat-sync-core-worker.ts | 818 +++++++++++------- .../chat-sync/discord-gateway-service.ts | 8 +- .../src/services/message-outbox-dispatcher.ts | 63 +- .../src/services/message-outbox-processor.ts | 10 +- .../message-side-effect-service.test.ts | 33 +- .../services/message-side-effect-service.ts | 304 +++---- .../backend/src/services/org-resolver.test.ts | 4 +- apps/backend/src/services/org-resolver.ts | 27 +- .../src/worker/outbox-dispatcher-object.ts | 13 +- packages/backend-core/src/index.ts | 1 - .../chat-sync-event-receipt-repo.ts | 15 + packages/domain/src/error-utils.ts | 4 +- packages/domain/src/errors.ts | 7 +- .../domain/src/http/api-v1/integrations.ts | 63 +- packages/domain/src/session-errors.ts | 1 - 23 files changed, 921 insertions(+), 613 deletions(-) diff --git a/apps/backend/src/lib/error-reporter.test.ts b/apps/backend/src/lib/error-reporter.test.ts index 9ac4e1fa5..abd036ac2 100644 --- a/apps/backend/src/lib/error-reporter.test.ts +++ b/apps/backend/src/lib/error-reporter.test.ts @@ -16,7 +16,9 @@ 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) })), + Effect.fail( + new InternalServerError({ message: "Error selecting user", cause: new Error(driverText) }), + ), ), ) diff --git a/apps/backend/src/routes/integrations.http.ts b/apps/backend/src/routes/integrations.http.ts index e77b2ea98..d92f65c1d 100644 --- a/apps/backend/src/routes/integrations.http.ts +++ b/apps/backend/src/routes/integrations.http.ts @@ -403,9 +403,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( }, ).pipe( Effect.catchTag("HttpBodyError", (e) => - Effect.fail( - new InternalServerError({ message: "Failed to serialize response", cause: e }), - ), + Effect.fail(new InternalServerError({ message: "Failed to serialize response", cause: e })), ), ) }) @@ -1209,9 +1207,7 @@ 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", cause: err }), - ), + Effect.fail(new InternalServerError({ message: "Missing configuration", cause: err })), ), ), ) @@ -1226,9 +1222,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" ), ), Effect.catchTag("ConfigError", (err) => - Effect.fail( - new InternalServerError({ message: "Missing configuration", cause: err }), - ), + Effect.fail(new InternalServerError({ message: "Missing configuration", cause: err })), ), ), ) diff --git a/apps/backend/src/routes/webhooks.http.ts b/apps/backend/src/routes/webhooks.http.ts index 1f971753d..da06e6607 100644 --- a/apps/backend/src/routes/webhooks.http.ts +++ b/apps/backend/src/routes/webhooks.http.ts @@ -1,7 +1,11 @@ import { createHmac, timingSafeEqual } from "node:crypto" import { HttpApiBuilder } from "effect/http-api" import { HttpServerRequest } from "effect/http" -import { InternalServerError, WorkflowInitializationError, WorkflowServiceUnavailableError } from "@hazel/domain" +import { + InternalServerError, + WorkflowInitializationError, + WorkflowServiceUnavailableError, +} from "@hazel/domain" import { GitHubWebhookResponse, InvalidGitHubWebhookPayload, diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index fae5eae86..a16c7f07e 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 { @@ -157,9 +152,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() diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index 08667b57a..c8c14cc86 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -161,9 +161,9 @@ describe("AuthMiddlewareLive", () => { hazelSession: makeHazelSessionLayer({ enabled: true }), }) - expect(Result.isFailure(result) && ("_tag" in result.failure ? result.failure._tag : "unhandled")).toBe( - "InvalidBearerTokenError", - ) + 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 () => { @@ -179,9 +179,9 @@ describe("AuthMiddlewareLive", () => { ), }) - expect(Result.isFailure(result) && ("_tag" in result.failure ? result.failure._tag : "unhandled")).toBe( - "SessionLoadError", - ) + expect( + Result.isFailure(result) && ("_tag" in result.failure ? result.failure._tag : "unhandled"), + ).toBe("SessionLoadError") }) it("fails when no bearer token is provided", async () => { diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index c16564bb1..a79c329ac 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -1,6 +1,11 @@ import { Headers } from "effect/http" import { BotRepo, UserRepo } from "@hazel/backend-core" -import { CurrentUser, InvalidBearerTokenError, SessionLoadError, SessionNotProvidedError } from "@hazel/domain" +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" diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index 174c36a82..bac69404d 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -30,7 +30,11 @@ export const AuthorizationLive = Layer.effect( // Hazel session credential (native clients) const sessionUser = token === "" ? Option.none() : yield* hazelSession.verify(token) if (Option.isSome(sessionUser)) { - return yield* Effect.provideService(httpEffect, CurrentUser.Context, sessionUser.value) + return yield* Effect.provideService( + httpEffect, + CurrentUser.Context, + sessionUser.value, + ) } return yield* Effect.fail( 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..91d78e725 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<DiscordSyncApiError> 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>()( + "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<Record<string, Function>, 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) => + <A, E, R>(effect: Effect.Effect<A, E, R>) => + 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, ) { @@ -1052,7 +1120,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1264,16 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1350,448 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 (!updated) { + 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 (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 (!deleted) { + 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 (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, + }) + + if (!deleted) { + yield* adapter.deleteMessage({ + externalChannelId: normalizedLink.externalChannelId, + 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) + 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, - } - }) + 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 +1829,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array<unknown> = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1712,6 +1844,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1854,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1734,6 +1876,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array<unknown> = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1748,6 +1891,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1901,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1770,6 +1923,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array<unknown> = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1784,6 +1938,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1948,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1807,6 +1971,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk const targets = yield* getActiveOutboundTargets(reaction.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array<unknown> = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1821,6 +1986,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +1996,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1848,6 +2023,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk const targets = yield* getActiveOutboundTargets(payload.hazelChannelId, provider) let synced = 0 let failed = 0 + const retryable: Array<unknown> = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1862,6 +2038,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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 +2048,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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<DiscordGatewayService }).pipe( Effect.provide(DiscordLayer), Effect.catchCause((cause) => - 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<void, never, Socket.WebSocketConstructor> 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>()("OutboxLockError", { + step: Schema.Literals(["connect", "lock"]), + cause: Schema.Defect(), +}) {} + export class MessageOutboxDispatcher extends Context.Service<MessageOutboxDispatcher>()( "MessageOutboxDispatcher", { @@ -32,14 +38,17 @@ export class MessageOutboxDispatcher extends Context.Service<MessageOutboxDispat const releaseReservedConnection = (reserved: PoolClient) => 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<MessageOutboxDispat Effect.provideService(Database.Database, database), Effect.catch((error) => 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<MessageOutboxDispat ) }) + // Annotated for the recursion. `unknown` requirements are inherited from + // ChatSyncCoreWorkerMake, which is typed loosely to break a circular type (see index.ts). const campaignForLeadership = (): Effect.Effect<void, never, unknown> => Effect.gen(function* () { const reservedResult = yield* Effect.tryPromise({ try: (): Promise<PoolClient> => 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<MessageOutboxDispat reserved.query<{ locked: boolean }>("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<MessageOutboxDispat yield* runLeaderLoop.pipe( Effect.ensuring(releaseReservedConnection(reserved)), + // A defect stops the loop; log it and campaign again. Interruption is shutdown. Effect.catchCause((cause) => - 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<MessageOutboxProcess switch (event.eventType) { case "message_created": yield* sideEffects.handleMessageCreated( - Schema.decodeUnknownSync(MessageCreatedPayloadSchema)(event.payload), + yield* Schema.decodeUnknownEffect(MessageCreatedPayloadSchema)(event.payload), dedupeKey, ) break case "message_updated": yield* sideEffects.handleMessageUpdated( - Schema.decodeUnknownSync(MessageUpdatedPayloadSchema)(event.payload), + yield* Schema.decodeUnknownEffect(MessageUpdatedPayloadSchema)(event.payload), dedupeKey, ) break case "message_deleted": yield* sideEffects.handleMessageDeleted( - Schema.decodeUnknownSync(MessageDeletedPayloadSchema)(event.payload), + yield* Schema.decodeUnknownEffect(MessageDeletedPayloadSchema)(event.payload), dedupeKey, ) break case "reaction_created": yield* sideEffects.handleReactionCreated( - Schema.decodeUnknownSync(ReactionCreatedPayloadSchema)(event.payload), + yield* Schema.decodeUnknownEffect(ReactionCreatedPayloadSchema)(event.payload), dedupeKey, ) break case "reaction_deleted": yield* sideEffects.handleReactionDeleted( - Schema.decodeUnknownSync(ReactionDeletedPayloadSchema)(event.payload), + yield* Schema.decodeUnknownEffect(ReactionDeletedPayloadSchema)(event.payload), dedupeKey, ) break diff --git a/apps/backend/src/services/message-side-effect-service.test.ts b/apps/backend/src/services/message-side-effect-service.test.ts index 74a480f6a..ea755a6b2 100644 --- a/apps/backend/src/services/message-side-effect-service.test.ts +++ b/apps/backend/src/services/message-side-effect-service.test.ts @@ -2,7 +2,7 @@ import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http" import { randomUUID } from "node:crypto" import { Database, PG, schema } from "@hazel/db" import type { ChannelId, MessageId, MessageReactionId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Layer, Context } from "effect" +import { Context, 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 { configLayer, serviceShape } from "../test/effect-helpers" @@ -32,6 +32,7 @@ type DiscordCall = type WorkerOptions = { failCreate?: boolean + failUpdate?: boolean } const runServiceEffect = <A, E, R>( @@ -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>()( "MessageSideEffectService", { @@ -21,17 +26,16 @@ export class MessageSideEffectService extends Context.Service<MessageSideEffectS const clusterUrl = yield* Config.String("CLUSTER_URL") const client = yield* makeClusterClient(clusterUrl) + /** Fails rather than guessing: a guess of "none" would echo bot messages back to Discord. */ const resolveIntegrationBotUserId = Effect.fn( "MessageSideEffectService.resolveIntegrationBotUserId", )(function* () { - const integrationBot = yield* db - .first( - PG.from(schema.usersTable) - .select("id") - .where(($) => [$.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<MessageSideEffectS const handleMessageCreated = Effect.fn("MessageSideEffectService.handleMessageCreated")( function* (payload: MessageCreatedPayload, dedupeKey: string) { const integrationBotUserId = yield* resolveIntegrationBotUserId() - if (payload.authorId !== integrationBotUserId) { - yield* discordSyncWorker - .syncHazelMessageCreateToAllConnections(payload.messageId, dedupeKey) - .pipe( - Effect.catch((error) => - 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<MessageSideEffectS }, }) .pipe( - Effect.tapError((err) => - 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/org-resolver.test.ts b/apps/backend/src/services/org-resolver.test.ts index 654b99d69..542571685 100644 --- a/apps/backend/src/services/org-resolver.test.ts +++ b/apps/backend/src/services/org-resolver.test.ts @@ -159,7 +159,9 @@ describe("OrgResolver", () => { use((r) => r.requireScope(TEST_ORG_ID, "organizations:read", "Organization", "read")), layer, ) - expect(Result.isFailure(result) && (result.failure as { _tag: string })._tag).toBe("DatabaseError") + expect(Result.isFailure(result) && (result.failure as { _tag: string })._tag).toBe( + "DatabaseError", + ) }) }) diff --git a/apps/backend/src/services/org-resolver.ts b/apps/backend/src/services/org-resolver.ts index 3d6490192..908a211ae 100644 --- a/apps/backend/src/services/org-resolver.ts +++ b/apps/backend/src/services/org-resolver.ts @@ -47,8 +47,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -72,8 +71,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -95,8 +93,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member) || member.value.role !== "owner") { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -147,8 +144,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { } const ch = channel.value - const orgMember = yield* organizationMemberRepo - .findByOrgAndUser(ch.organizationId, actor.id) + const orgMember = yield* organizationMemberRepo.findByOrgAndUser(ch.organizationId, actor.id) if (Option.isNone(orgMember)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -204,14 +200,18 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { if (isAdminOrOwner(orgMember.role as OrganizationRole)) { return true } - const membership = yield* channelMemberRepo - .findByChannelAndUser(channel.id as ChannelId, actor.id) + 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) + const membership = yield* channelMemberRepo.findByChannelAndUser( + channel.id as ChannelId, + actor.id, + ) return Option.isSome(membership) } @@ -219,8 +219,7 @@ export class OrgResolver extends Context.Service<OrgResolver>()("OrgResolver", { if (!channel.parentChannelId) { return false } - const parentChannel = yield* channelRepo - .findById(channel.parentChannelId as ChannelId) + const parentChannel = yield* channelRepo.findById(channel.parentChannelId as ChannelId) if (Option.isNone(parentChannel)) { return false 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/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index c08d5cd0c..9a471dbae 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -45,4 +45,3 @@ export { RssSubscriptionRepo } from "./repositories/rss-subscription-repo" export { TypingIndicatorRepo } from "./repositories/typing-indicator-repo" export { UserPresenceStatusRepo } from "./repositories/user-presence-status-repo" export { UserRepo } from "./repositories/user-repo" - diff --git a/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts b/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts index c0f8c0b2e..ad9709f56 100644 --- a/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts +++ b/packages/backend-core/src/repositories/chat-sync-event-receipt-repo.ts @@ -100,6 +100,20 @@ export class ChatSyncEventReceiptRepo extends Context.Service<ChatSyncEventRecei ) } + /** Drops a claim so the same delivery can be claimed again (after a retryable failure). */ + const releaseByDedupeKey = (params: { + syncConnectionId: SyncConnectionId + source: ChatSyncEventReceipt.ChatSyncReceiptSource + dedupeKey: string + }) => + db.run( + PG.deleteFrom(schema.chatSyncEventReceiptsTable).where(($) => [ + $.syncConnectionId.eq(params.syncConnectionId), + $.source.eq(params.source), + $.dedupeKey.eq(params.dedupeKey), + ]), + ) + const findRecentByConnection = (syncConnectionId: SyncConnectionId, processedAfter: Date) => db.run( PG.from(schema.chatSyncEventReceiptsTable) @@ -126,6 +140,7 @@ export class ChatSyncEventReceiptRepo extends Context.Service<ChatSyncEventRecei findByDedupeKey, claimByDedupeKey, updateByDedupeKey, + releaseByDedupeKey, findRecentByConnection, markFailed, } diff --git a/packages/domain/src/error-utils.ts b/packages/domain/src/error-utils.ts index eeee25c30..b4bf8d93d 100644 --- a/packages/domain/src/error-utils.ts +++ b/packages/domain/src/error-utils.ts @@ -47,7 +47,9 @@ export const refailUnauthorized = (entity: string, action: string) => { Effect.fail( new UnauthorizedError({ // Convert PermissionError to UnauthorizedError with scope info - message: PermissionError.is(e) ? e.message : `You can't ${action} this ${entity}`, + message: PermissionError.is(e) + ? e.message + : `You can't ${action} this ${entity}`, detail: `You are not authorized to perform ${action} on ${entity} for ${actor.id}`, }), ), diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index a4e22d467..8857aba23 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -61,7 +61,12 @@ export class OAuthRedemptionPendingError extends Schema.TaggedError<OAuthRedempt */ export const attachCause = (error: Error, cause: unknown) => { if (cause === undefined) return - Object.defineProperty(error, "cause", { value: cause, enumerable: false, writable: true, configurable: true }) + Object.defineProperty(error, "cause", { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }) } /** diff --git a/packages/domain/src/http/api-v1/integrations.ts b/packages/domain/src/http/api-v1/integrations.ts index c2b9505f4..e6aeb6d09 100644 --- a/packages/domain/src/http/api-v1/integrations.ts +++ b/packages/domain/src/http/api-v1/integrations.ts @@ -100,53 +100,40 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("api-v1-integrations HttpApiEndpoint.get("listOrganizations", "/organizations", { success: ApiV1OrganizationsListResponse, error: [UnauthorizedError, SessionLoadError, InternalServerError], - }) - .annotateMerge( - OpenApi.annotations({ - title: "List Organizations", - description: - "List the organizations the OAuth user is a member of. Used by integrations to render a workspace picker.", - summary: "List organizations", - }), - ), + }).annotateMerge( + OpenApi.annotations({ + title: "List Organizations", + description: + "List the organizations the OAuth user is a member of. Used by integrations to render a workspace picker.", + summary: "List organizations", + }), + ), ) .add( HttpApiEndpoint.get("listChannels", "/organizations/:organizationId/channels", { params: { organizationId: OrganizationId }, success: ApiV1ChannelsListResponse, - error: [ - UnauthorizedError, - SessionLoadError, - ApiV1OrganizationNotFoundError, - InternalServerError, - ], - }) - .annotateMerge( - OpenApi.annotations({ - title: "List Channels", - description: "List the channels in an organization the OAuth user is a member of.", - summary: "List channels", - }), - ), + error: [UnauthorizedError, SessionLoadError, ApiV1OrganizationNotFoundError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "List Channels", + description: "List the channels in an organization the OAuth user is a member of.", + summary: "List channels", + }), + ), ) .add( HttpApiEndpoint.post("createChannelWebhook", "/channel-webhooks", { payload: ApiV1CreateChannelWebhookRequest, success: ApiV1ChannelWebhookCreatedResponse, - error: [ - UnauthorizedError, - SessionLoadError, - ApiV1ChannelNotFoundError, - InternalServerError, - ], - }) - .annotateMerge( - OpenApi.annotations({ - title: "Create Channel Webhook", - description: - "Provision an inbound webhook bound to a channel. Returns a `webhookUrl` integrations should POST to for delivery (uses the same path as the manual webhook flow).", - summary: "Create channel webhook", - }), - ), + error: [UnauthorizedError, SessionLoadError, ApiV1ChannelNotFoundError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "Create Channel Webhook", + description: + "Provision an inbound webhook bound to a channel. Returns a `webhookUrl` integrations should POST to for delivery (uses the same path as the manual webhook flow).", + summary: "Create channel webhook", + }), + ), ) .prefix("/api/v1") {} diff --git a/packages/domain/src/session-errors.ts b/packages/domain/src/session-errors.ts index d2f5e5935..5b6c7e356 100644 --- a/packages/domain/src/session-errors.ts +++ b/packages/domain/src/session-errors.ts @@ -72,4 +72,3 @@ export class SessionRefreshError extends Schema.TaggedError<SessionRefreshError> }, { httpApiStatus: 401 }, ) {} - From f53581ff96ddfe08b2d2aa50b14c6f6c57a7549c Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:06:16 +0200 Subject: [PATCH 14/52] fix(backend): answer bad input with 400s and missing records with not-found errors Validation and not-found cases came back as InternalServerError (500), so clients could not tell them from outages: - channel.createDm / createThread / generateName: InvalidChannelRequestError (400) for the participant count, org mismatch, non-thread channels and a thread without its original message (which used to fake a MessageNotFoundError from the channel id, and turned a DB error into "not found"). - Incoming webhooks: InvalidWebhookPayloadError (400) for no content, too many embeds and a payload that fails the message schema. - connectShare.*: ChannelNotFoundError and a new ConnectConversationNotFoundError; the two `throw`s inside Option.getOrElse (untyped defects) are typed failures. - invitation.create propagates OrganizationNotFoundError instead of rewrapping it. The RPC and HttpApi error unions are widened accordingly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/routes/incoming-webhooks.http.ts | 71 ++++++++-------- apps/backend/src/rpc/handlers/channels.ts | 49 ++++------- .../src/rpc/handlers/connect-shares.ts | 81 ++++++++----------- .../services/connect-conversation-service.ts | 5 +- apps/backend/src/services/invitations.test.ts | 26 ++++++ apps/backend/src/services/invitations.ts | 6 +- packages/domain/src/errors.ts | 11 +++ packages/domain/src/http/incoming-webhooks.ts | 13 +++ packages/domain/src/rpc/channels.ts | 15 +++- packages/domain/src/rpc/connect-shares.ts | 33 +++++++- packages/domain/src/rpc/invitations.ts | 2 +- 11 files changed, 183 insertions(+), 129 deletions(-) diff --git a/apps/backend/src/routes/incoming-webhooks.http.ts b/apps/backend/src/routes/incoming-webhooks.http.ts index 74a6e3470..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", 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", - cause: 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", 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", - cause: 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", 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", - cause: 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", 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", - cause: error, + new InvalidWebhookPayloadError({ + message: "The payload does not make a valid message", }), ), }), diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 01bedcf6b..541ebcd33 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -10,6 +10,7 @@ import { CurrentUser, DmChannelAlreadyExistsError, InternalServerError, + InvalidChannelRequestError, withRemapDbErrors, WorkflowServiceUnavailableError, } from "@hazel/domain" @@ -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: 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,13 +481,6 @@ 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) => diff --git a/apps/backend/src/rpc/handlers/connect-shares.ts b/apps/backend/src/rpc/handlers/connect-shares.ts index b4359aec6..de2d17d80 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, @@ -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/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<ConnectConversat 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/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..c90762979 100644 --- a/apps/backend/src/services/invitations.ts +++ b/apps/backend/src/services/invitations.ts @@ -111,11 +111,7 @@ export class Invitations extends Context.Service<Invitations>()("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/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index 8857aba23..c0fd77b92 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -113,6 +113,17 @@ export class WorkflowInitializationError extends Schema.TaggedError<WorkflowInit } } +/** The request does not fit the channel or is malformed (e.g. naming a channel that is not a thread). */ +export class InvalidChannelRequestError extends Schema.TaggedError<InvalidChannelRequestError>( + "InvalidChannelRequestError", +)( + "InvalidChannelRequestError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + export class DmChannelAlreadyExistsError extends Schema.TaggedError<DmChannelAlreadyExistsError>( "DmChannelAlreadyExistsError", )( diff --git a/packages/domain/src/http/incoming-webhooks.ts b/packages/domain/src/http/incoming-webhooks.ts index bb6768ad0..26708ec07 100644 --- a/packages/domain/src/http/incoming-webhooks.ts +++ b/packages/domain/src/http/incoming-webhooks.ts @@ -180,6 +180,15 @@ export class InvalidWebhookTokenError extends Schema.TaggedError<InvalidWebhookT { httpApiStatus: 401 }, ) {} +// Error: the payload is valid JSON for the endpoint but cannot become a message +export class InvalidWebhookPayloadError extends Schema.TaggedError<InvalidWebhookPayloadError>()( + "InvalidWebhookPayloadError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + // Public endpoint - no auth middleware, uses webhook token in URL export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") .add( @@ -194,6 +203,7 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") WebhookNotFoundError, WebhookDisabledError, InvalidWebhookTokenError, + InvalidWebhookPayloadError, InternalServerError, ], }) @@ -219,6 +229,7 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") WebhookNotFoundError, WebhookDisabledError, InvalidWebhookTokenError, + InvalidWebhookPayloadError, InternalServerError, ], }) @@ -244,6 +255,7 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") WebhookNotFoundError, WebhookDisabledError, InvalidWebhookTokenError, + InvalidWebhookPayloadError, InternalServerError, ], }) @@ -269,6 +281,7 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks") WebhookNotFoundError, WebhookDisabledError, InvalidWebhookTokenError, + InvalidWebhookPayloadError, InternalServerError, ], }) diff --git a/packages/domain/src/rpc/channels.ts b/packages/domain/src/rpc/channels.ts index 6401f95bc..bff5575ac 100644 --- a/packages/domain/src/rpc/channels.ts +++ b/packages/domain/src/rpc/channels.ts @@ -6,6 +6,7 @@ import { AIResponseParseError, DmChannelAlreadyExistsError, InternalServerError, + InvalidChannelRequestError, MessageNotFoundError, NestedThreadError, OriginalMessageNotFoundError, @@ -151,7 +152,12 @@ export class ChannelRpcs extends RpcGroup.make( Rpc.make("channel.createDm", { payload: CreateDmChannelRequest, success: ChannelResponse, - error: Schema.Union([DmChannelAlreadyExistsError, UnauthorizedError, InternalServerError]), + error: Schema.Union([ + DmChannelAlreadyExistsError, + InvalidChannelRequestError, + UnauthorizedError, + InternalServerError, + ]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -176,6 +182,7 @@ export class ChannelRpcs extends RpcGroup.make( error: Schema.Union([ MessageNotFoundError, NestedThreadError, + InvalidChannelRequestError, UnauthorizedError, InternalServerError, ]), @@ -192,8 +199,8 @@ export class ChannelRpcs extends RpcGroup.make( * * @param payload - Thread channel ID * @returns Transaction ID - * @throws ChannelNotFoundError if channel doesn't exist or is not a thread - * @throws MessageNotFoundError if original message is not found + * @throws ChannelNotFoundError if channel doesn't exist + * @throws InvalidChannelRequestError if the channel is not a thread or has no original message * @throws UnauthorizedError if user lacks permission * @throws InternalServerError for unexpected errors * @throws ThreadChannelNotFoundError if thread channel not found in workflow @@ -209,7 +216,7 @@ export class ChannelRpcs extends RpcGroup.make( success: Schema.Struct({ success: Schema.Boolean }), error: Schema.Union([ ChannelNotFoundError, - MessageNotFoundError, + InvalidChannelRequestError, UnauthorizedError, InternalServerError, WorkflowServiceUnavailableError, diff --git a/packages/domain/src/rpc/connect-shares.ts b/packages/domain/src/rpc/connect-shares.ts index b34399671..ac10af09d 100644 --- a/packages/domain/src/rpc/connect-shares.ts +++ b/packages/domain/src/rpc/connect-shares.ts @@ -10,6 +10,7 @@ import { } from "@hazel/schema" import { ConnectConversation, ConnectConversationChannel, ConnectInvite, ConnectParticipant } from "../models" import { InternalServerError, UnauthorizedError } from "../errors" +import { ChannelNotFoundError } from "./channels" import { AuthMiddleware } from "./middleware" import { RequiredScopes } from "../scopes/required-scopes" @@ -77,6 +78,15 @@ export class ConnectWorkspaceNotFoundError extends Schema.TaggedError<ConnectWor }, ) {} +/** The conversation does not exist, or the channel is not shared through one. */ +export class ConnectConversationNotFoundError extends Schema.TaggedError<ConnectConversationNotFoundError>()( + "ConnectConversationNotFoundError", + { + conversationId: Schema.optional(ConnectConversationId), + message: Schema.String, + }, +) {} + export class ConnectChannelAlreadySharedError extends Schema.TaggedError<ConnectChannelAlreadySharedError>()( "ConnectChannelAlreadySharedError", { @@ -110,6 +120,7 @@ export class ConnectShareRpcs extends RpcGroup.make( }), success: ConnectInviteResponse, error: Schema.Union([ + ChannelNotFoundError, ConnectWorkspaceNotFoundError, ConnectChannelAlreadySharedError, UnauthorizedError, @@ -130,6 +141,8 @@ export class ConnectShareRpcs extends RpcGroup.make( ConnectInviteInvalidStateError, ConnectWorkspaceNotFoundError, ConnectChannelAlreadySharedError, + ConnectConversationNotFoundError, + ChannelNotFoundError, UnauthorizedError, InternalServerError, ]), @@ -195,7 +208,7 @@ export class ConnectShareRpcs extends RpcGroup.make( status: Schema.optional(ConnectConversation.ConnectConversationStatus), }), success: ConnectConversationResponse, - error: Schema.Union([UnauthorizedError, InternalServerError]), + error: Schema.Union([ConnectConversationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["channels:write"]) .middleware(AuthMiddleware), @@ -206,7 +219,13 @@ export class ConnectShareRpcs extends RpcGroup.make( userId: UserId, }), success: ConnectParticipantResponse, - error: Schema.Union([ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError]), + error: Schema.Union([ + ChannelNotFoundError, + ConnectConversationNotFoundError, + ConnectWorkspaceNotFoundError, + UnauthorizedError, + InternalServerError, + ]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -217,7 +236,13 @@ export class ConnectShareRpcs extends RpcGroup.make( userId: UserId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union([ConnectWorkspaceNotFoundError, UnauthorizedError, InternalServerError]), + error: Schema.Union([ + ChannelNotFoundError, + ConnectConversationNotFoundError, + ConnectWorkspaceNotFoundError, + UnauthorizedError, + InternalServerError, + ]), }) .annotate(RequiredScopes, ["channel-members:write"]) .middleware(AuthMiddleware), @@ -228,7 +253,7 @@ export class ConnectShareRpcs extends RpcGroup.make( organizationId: OrganizationId, }), success: Schema.Struct({ transactionId: TransactionId }), - error: Schema.Union([UnauthorizedError, InternalServerError]), + error: Schema.Union([ConnectConversationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organizations:write"]) .middleware(AuthMiddleware), diff --git a/packages/domain/src/rpc/invitations.ts b/packages/domain/src/rpc/invitations.ts index 97a46e67a..1806df6d1 100644 --- a/packages/domain/src/rpc/invitations.ts +++ b/packages/domain/src/rpc/invitations.ts @@ -83,7 +83,7 @@ export class InvitationRpcs extends RpcGroup.make( role: InvitationRole, }), success: InvitationCreateResult, - error: Schema.Union([UnauthorizedError, InternalServerError]), + error: Schema.Union([OrganizationNotFoundError, UnauthorizedError, InternalServerError]), }) .annotate(RequiredScopes, ["organization-members:write"]) .middleware(AuthMiddleware), From a9e21a5933de874b2e637b9b1ac7ab38829f5665 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:08:23 +0200 Subject: [PATCH 15/52] fix(auth): fail sign-in when verified-email linking fails instead of registering linkByVerifiedEmail caught every cause (defects and interruption included) and continued to registration. `users.email` is not unique, so a transient database error during the lookup or the link created a second account for an existing user. SqlError and PlatformError now fail the intent with OAuthUnavailable (the user retries sign-in) after logging the cause. Covered by a Postgres test that breaks the users lookup mid sign-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/auth/src/yielded/hazel-auth.test.ts | 32 ++++++++++++++++++++ packages/auth/src/yielded/storage.ts | 26 +++++++++++----- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index f2bf4b480..836998303 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -468,6 +468,38 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { expect((await signInWithGitHub(browser)).pathname).toBe("/auth/register-profile") }, 60_000) + test("a failing email lookup fails sign-in instead of continuing to registration", async () => { + await insertClerkUser("octo6767@example.com") + githubUserId = 6767 + const browser = jar() + const begin = await call(browser, "POST", "/auth/signIn", { provider: "github", returnTarget: "/" }) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + + // Break only the users lookup; registration would otherwise create a duplicate account. + await query( + SqlClient.SqlClient.use((sql) => + sql.unsafe(`ALTER TABLE users RENAME COLUMN email TO email_moved`), + ), + ) + try { + const callback = await send( + new Request( + `${origin}/auth/github/callback?${new URLSearchParams({ code: "abc", state, iss: "https://github.com/login/oauth" })}`, + { headers: { cookie: browser.header() }, redirect: "manual" }, + ), + ) + const location = callback.headers.get("location") + expect(location === null ? null : new URL(location).pathname).not.toBe("/auth/register-profile") + } finally { + await query( + SqlClient.SqlClient.use((sql) => + sql.unsafe(`ALTER TABLE users RENAME COLUMN email_moved TO email`), + ), + ) + } + expect(await usersWithEmail("octo6767@example.com")).toBe(1) + }, 60_000) + test("the desktop app signs in through the browser and gets its own session", async () => { // A signed-in browser, as on app.hazel.sh. githubUserId = 7777 diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts index f4b032b3b..24e8ad487 100644 --- a/packages/auth/src/yielded/storage.ts +++ b/packages/auth/src/yielded/storage.ts @@ -2,7 +2,7 @@ import { OAuth, Schema as AuthSchema, Sessions } from "@yielded/auth" import { AuthPersistence, PersistenceMappingError } from "@yielded/auth-persistence" import type { SubjectIdCodec } from "@yielded/auth-persistence/Adapter" import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" -import { Context, Crypto, Effect, Layer, Schema } from "effect" +import { Cause, Context, Crypto, Effect, Layer, Schema } from "effect" import { SqlClient } from "effect/sql" import { HazelAuth, requirement } from "./auth.ts" @@ -144,10 +144,10 @@ const emailFromIntent = (intent: OAuth.OAuthRegistrationIntent) => /** * A first-time GitHub or Google sign-in whose provider-verified email belongs to exactly - * one existing user is linked to that user (users who signed up through Clerk with - * email). The registration intent is still issued; the callback then sees the link and - * restarts sign-in, which resolves to the existing account. Matching failures only log: - * the user falls back to registering. + * one existing user is linked to that user (users who signed up before Hazel sign-in, + * with email). The registration intent is still issued; the callback then sees the link and + * restarts sign-in, which resolves to the existing account. If the lookup or the link + * fails, sign-in fails with OAuthUnavailable rather than continuing to registration. */ const linkByVerifiedEmail = (intent: OAuth.OAuthRegistrationIntent) => Effect.gen(function* () { @@ -166,9 +166,19 @@ const linkByVerifiedEmail = (intent: OAuth.OAuthRegistrationIntent) => result: linked._tag, }) }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("[auth] verified-email linking failed; continuing to registration", cause), - ), + // Linking must not quietly fall through: registering instead would create a second + // account for the same email (`users.email` is not unique). An unavailable sign-in the + // user can retry is the honest answer. + Effect.catchTags({ + SqlError: (cause) => + Effect.logError("[auth] verified-email linking failed", Cause.fail(cause)).pipe( + Effect.andThen(Effect.fail(OAuth.OAuthUnavailable.make({}))), + ), + PlatformError: (cause) => + Effect.logError("[auth] verified-email linking failed", Cause.fail(cause)).pipe( + Effect.andThen(Effect.fail(OAuth.OAuthUnavailable.make({}))), + ), + }), ) export const OAuthStorageLive = Layer.effectContext( From 52c85806ed954cf34f4fa281355fbb9dbe67ec81 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:09:19 +0200 Subject: [PATCH 16/52] refactor(web): Menu entries come from the parent through view inputs The Menu Model now holds only the popup, selection mode and selected keys. Parents pass entries in ViewInputs; the view attaches them to the Messages that navigate (PressedTriggerKey, PressedMenuKey, HoveredItem, ClickedItem), so reflectEntries and the copied entries are gone. The shell keeps its menus' entries in its own Model so the memoized sidebars see stable references. Overlays.update loses its now-unused facts argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/app/modal-host.scene.test.ts | 14 ++- .../src/app/out-messages.story.test.ts | 3 +- apps/web-foldkit/src/app/view.ts | 2 + .../channel-settings/integrations/cards.ts | 1 + .../channel-settings/integrations/row-menu.ts | 4 + .../channel-settings/integrations/rows.ts | 8 +- .../channel-settings/integrations/rss-card.ts | 1 + .../integrations/story.test.ts | 7 +- .../channel-settings/integrations/webhooks.ts | 1 + .../web-foldkit/src/page/chat/channel/page.ts | 4 +- .../src/page/chat/channel/read.story.test.ts | 9 +- .../src/page/chat/overlay-views.ts | 3 +- apps/web-foldkit/src/page/chat/overlays.ts | 14 +-- apps/web-foldkit/src/page/chat/row-context.ts | 1 + apps/web-foldkit/src/page/home/story.test.ts | 7 +- apps/web-foldkit/src/page/home/update.ts | 4 +- apps/web-foldkit/src/page/home/view.ts | 3 +- .../settings/chat-sync-connection/parts.ts | 2 + .../chat-sync-connection/story.test.ts | 5 +- .../settings/chat-sync-connection/update.ts | 8 +- .../src/page/settings/chat-sync/update.ts | 10 +- .../src/page/settings/chat-sync/view.ts | 3 +- .../settings/integrations/shared/bot-card.ts | 1 + .../integrations/your-apps/story.test.ts | 5 +- .../settings/integrations/your-apps/update.ts | 4 +- .../page/settings/invitations/page.test.ts | 4 +- .../page/settings/invitations/story.test.ts | 11 +- .../src/page/settings/invitations/update.ts | 38 +++---- .../src/page/settings/invitations/view.ts | 2 + .../web-foldkit/src/shell/channels-sidebar.ts | 14 ++- .../src/shell/channels-sidebar/menu-update.ts | 11 +- .../src/shell/channels-sidebar/menus.ts | 16 ++- .../src/shell/channels-sidebar/view.ts | 3 +- apps/web-foldkit/src/shell/menus.ts | 4 + apps/web-foldkit/src/shell/model.ts | 8 +- .../web-foldkit/src/shell/shell.story.test.ts | 32 +++--- apps/web-foldkit/src/shell/sidebars.ts | 21 +++- apps/web-foldkit/src/shell/update.ts | 31 +++--- apps/web-foldkit/src/ui/menu-view.ts | 13 ++- apps/web-foldkit/src/ui/menu.scene.test.ts | 35 +++--- apps/web-foldkit/src/ui/menu.story.test.ts | 38 +++---- apps/web-foldkit/src/ui/menu.ts | 100 ++++++++++-------- 42 files changed, 280 insertions(+), 225 deletions(-) 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..4c5c2a6b8 100644 --- a/apps/web-foldkit/src/app/modal-host.scene.test.ts +++ b/apps/web-foldkit/src/app/modal-host.scene.test.ts @@ -2,6 +2,7 @@ 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 * as Menu from "../ui/menu" @@ -17,7 +18,18 @@ 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" }) 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..1d50d7a57 100644 --- a/apps/web-foldkit/src/app/out-messages.story.test.ts +++ b/apps/web-foldkit/src/app/out-messages.story.test.ts @@ -7,6 +7,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" @@ -122,7 +123,7 @@ describe("RequestedSignOut", () => { 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()), ) diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index f217371dc..1a60521e8 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -118,6 +118,8 @@ const secondarySidebar = (model: Model, h: HtmlBuilder<Message>, 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), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts b/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts index ced8955fc..2e6b2e66a 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts @@ -118,6 +118,7 @@ const gitHubItem = (h: HtmlBuilder<Message>, model: Model, repo: GitHubRepo, log id: repo.id, menu: menuOf(model, "github", repo.id), isEnabled: repo.isEnabled, + isToggling: model.togglingRowIds.includes(repo.id), triggerClassName: "text-muted-fg", labels: { enable: "Enable", disable: "Disable", remove: "Remove" }, }), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts b/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts index 90094928b..6842fbd5c 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts @@ -5,6 +5,7 @@ import type * as Menu from "../../../ui/menu" import { menuLabel, view as menuView } from "../../../ui/menu-view" import { Message } from "./message" import type { RowKind } from "./model" +import { rowEntries } from "./rows" /** The rows' `<Menu><Button intent="plain" size="sq-xs" />` and its items. */ export const rowMenu = ( @@ -14,6 +15,8 @@ export const rowMenu = ( id: string menu: Menu.Model | undefined isEnabled: boolean + /** The toggle item is disabled while its request runs. */ + isToggling: boolean triggerClassName: string labels: Readonly<{ enable: string; disable: string; remove: string }> }>, @@ -39,6 +42,7 @@ export const rowMenu = ( model: menu, view: menuView, viewInputs: { + entries: rowEntries(options.kind, options.isToggling), toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => button( h, diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts b/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts index c6a2810c6..209841895 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts @@ -28,13 +28,9 @@ export const reflectRowMenus = (model: Model): Model => rowMenus: (menus) => rowKeys(model).map(([kind, id]) => { const menuId = rowMenuId(kind, id) - const entries = rowEntries(kind, model.togglingRowIds.includes(id)) - return Option.match( + return Option.getOrElse( Array.findFirst(menus, (menu) => menu.id === menuId), - { - onNone: () => Menu.init({ id: menuId, entries, placement: "bottom end" }), - onSome: (menu) => Menu.reflectEntries(menu, entries), - }, + () => Menu.init({ id: menuId, placement: "bottom end" }), ) }), }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts b/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts index a8277520c..d829d2553 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts @@ -92,6 +92,7 @@ const rssItem = (h: HtmlBuilder<Message>, model: Model, feed: RssFeed): Html => id: feed.id, menu: menuOf(model, "rss", feed.id), isEnabled: feed.isEnabled, + isToggling: model.togglingRowIds.includes(feed.id), triggerClassName: "shrink-0 text-muted-fg", labels: { enable: "Resume", disable: "Pause", remove: "Remove" }, }), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts index c31d24f5f..2c75b222e 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts @@ -22,6 +22,7 @@ import { import { Message } from "./message" import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" +import { rowEntries } from "./rows" const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` const channelId = Schema.decodeSync(ChannelId)(uuid(1)) @@ -65,7 +66,7 @@ describe("channel integrations", () => { given(loaded), message(menu(Menu.Message.PressedTrigger({ pointerType: "mouse" }))), Command.resolveAll(), - message(menu(Menu.Message.ClickedItem({ key: "toggle" }))), + message(menu(Menu.Message.ClickedItem({ key: "toggle", entries: rowEntries("webhook", false) }))), Command.resolveAll(), model((current) => expect(current.togglingRowIds).toEqual([ci.id])), Command.resolve( @@ -153,7 +154,7 @@ const rowMenu = (menuMessage: Menu.Message) => /** The custom webhook's menu opened and "Delete" picked: the confirm dialog is open. */ const confirming = [ Menu.Message.PressedTrigger({ pointerType: "mouse" }), - Menu.Message.ClickedItem({ key: "remove" }), + Menu.Message.ClickedItem({ key: "remove", entries: rowEntries("webhook", false) }), ].reduce((current, next) => run(current, rowMenu(next)).model, loaded) describe("row removal", () => { @@ -241,7 +242,7 @@ describe("row removal", () => { Message.SucceededRowAction({ kind: "webhook", id: ci.id, successMessage: "Webhook deleted" }), ).model const reopened = run(settled, rowMenu(Menu.Message.PressedTrigger({ pointerType: "mouse" }))).model - const again = run(reopened, rowMenu(Menu.Message.ClickedItem({ key: "remove" }))).model + const again = run(reopened, rowMenu(Menu.Message.ClickedItem({ key: "remove", entries: rowEntries("webhook", false) }))).model expect(run(again, Message.ClickedConfirmRemove()).commands ?? []).toHaveLength(1) }) }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts index ae610aeb2..17061c659 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts @@ -98,6 +98,7 @@ const compactWebhookItem = ( id: webhook.id, menu: model.rowMenus.find((menu) => menu.id === rowMenuId("webhook", webhook.id)), isEnabled: webhook.isEnabled, + isToggling: model.togglingRowIds.includes(webhook.id), triggerClassName: "text-muted-fg", labels: { enable: "Enable", disable: "Disable", remove: "Delete" }, }), diff --git a/apps/web-foldkit/src/page/chat/channel/page.ts b/apps/web-foldkit/src/page/chat/channel/page.ts index 1835273ba..e1a9f18f5 100644 --- a/apps/web-foldkit/src/page/chat/channel/page.ts +++ b/apps/web-foldkit/src/page/chat/channel/page.ts @@ -1,4 +1,4 @@ -import type { ChannelId, ChannelMemberId, MessageId, UserId } from "@hazel/schema" +import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { Record, Struct } from "effect" import { Command } from "foldkit" import * as Live from "../../../chat/live-state" @@ -339,7 +339,7 @@ export const update = (model: Model, message: Message, shared: Shared | null = n GotListMessage: ({ message: listMessage }) => loadWhenNearEdge(liftList(model, MessageList.update(model.list, listMessage))), GotOverlaysMessage: ({ message: overlaysMessage }) => - liftOverlays(model, Overlays.update(model.overlays, overlaysMessage, factsOf(model))), + liftOverlays(model, Overlays.update(model.overlays, overlaysMessage)), // Reported to the root as `RequestedMobileSidebar` by `index.ts`. ClickedMobileMenu: () => ({ model }), GotDraftMessage: ({ message: draftMessage }) => Write.updateDraft(model, "channel", draftMessage, shared), diff --git a/apps/web-foldkit/src/page/chat/channel/read.story.test.ts b/apps/web-foldkit/src/page/chat/channel/read.story.test.ts index fb55d3d17..9e68465d3 100644 --- a/apps/web-foldkit/src/page/chat/channel/read.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/read.story.test.ts @@ -434,22 +434,19 @@ describe("image viewer", () => { }) test("re-entering the hovered message cancels a pending hide; moving within it changes nothing", () => { - const facts = { isOwnMessage: () => false, isPinned: () => false, isThreadChannel: false } const entered = Overlays.update( Overlays.init(), Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), - facts, ).model - const within = Overlays.update(entered, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), facts) + const within = Overlays.update(entered, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId })) expect(within.model).toBe(entered) - const left = Overlays.update(entered, Overlays.Message.PointerLeftList(), facts).model + const left = Overlays.update(entered, Overlays.Message.PointerLeftList()).model expect(left.isHidePending).toBe(true) - const back = Overlays.update(left, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), facts).model + const back = Overlays.update(left, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId })).model expect(back.isHidePending).toBe(false) const stale = Overlays.update( back, Overlays.Message.CompletedWaitForHideToolbar({ version: left.hoverVersion }), - facts, ).model expect(stale.hoveredMessageId).toBe(graceMessageId) }) diff --git a/apps/web-foldkit/src/page/chat/overlay-views.ts b/apps/web-foldkit/src/page/chat/overlay-views.ts index d0a4fad29..89ede476b 100644 --- a/apps/web-foldkit/src/page/chat/overlay-views.ts +++ b/apps/web-foldkit/src/page/chat/overlay-views.ts @@ -58,13 +58,14 @@ export const messageToolbarOverlay = <M>( if (messageId === null || row === undefined || row._tag !== "MessageRow") return h.empty const toOverlay = toOverlays(toParentMessage) const facts = factsOf(model) - const menu = Overlays.moreMenuFor(overlays, messageId, facts) + const menu = Overlays.moreMenuFor(overlays, messageId) const isPinned = facts.isPinned(messageId) const moreActions = h.submodel({ slotId: "message-more-actions", model: menu, view: menuView, viewInputs: { + entries: Overlays.moreMenuEntries(facts), toTrigger: (attributes, overlay) => h.button( [ diff --git a/apps/web-foldkit/src/page/chat/overlays.ts b/apps/web-foldkit/src/page/chat/overlays.ts index 4b62a7eb1..895b6e953 100644 --- a/apps/web-foldkit/src/page/chat/overlays.ts +++ b/apps/web-foldkit/src/page/chat/overlays.ts @@ -208,12 +208,12 @@ export const contextMenuEntries = (facts: MessageFacts, messageId: MessageId): R ] /** The closed "More actions" menu of a message. */ -export const moreMenuFor = (model: Model, messageId: MessageId, facts: MessageFacts): Menu.Model => +export const moreMenuFor = (model: Model, messageId: MessageId): Menu.Model => model.moreMenu !== null && model.moreMenu.messageId === messageId ? model.moreMenu.menu - : Menu.init({ id: `more-${messageId}`, entries: moreMenuEntries(facts), placement: "bottom end" }) + : Menu.init({ id: `more-${messageId}`, placement: "bottom end" }) -export const update = (model: Model, message: Message, facts: MessageFacts): OverlaysReturn => +export const update = (model: Model, message: Message): OverlaysReturn => Message.match<OverlaysReturn>(message, { // `pointerover` fires for every child entered: within the hovered message only a pending hide changes. PointerEnteredMessage: ({ messageId }) => @@ -279,7 +279,7 @@ export const update = (model: Model, message: Message, facts: MessageFacts): Ove } }, GotMoreMenuMessage: ({ messageId, message: menuMessage }) => { - const result = Menu.update(moreMenuFor(model, messageId, facts), menuMessage) + const result = Menu.update(moreMenuFor(model, messageId), menuMessage) return withSelection( mapped( model, @@ -292,11 +292,7 @@ export const update = (model: Model, message: Message, facts: MessageFacts): Ove ) }, RightClickedMessage: ({ messageId, offset, crossOffset }) => { - const menu = Menu.init({ - id: "message-context-menu", - anchor: "Pointer", - entries: contextMenuEntries(facts, messageId), - }) + const menu = Menu.init({ id: "message-context-menu", anchor: "Pointer" }) return mapped( model, Menu.update(menu, Menu.Message.PressedContextMenu({ offset, crossOffset })), diff --git a/apps/web-foldkit/src/page/chat/row-context.ts b/apps/web-foldkit/src/page/chat/row-context.ts index eb0f7310c..95b476bbe 100644 --- a/apps/web-foldkit/src/page/chat/row-context.ts +++ b/apps/web-foldkit/src/page/chat/row-context.ts @@ -119,6 +119,7 @@ const buildContext = <M>( model: open.menu, view: contextMenuView, viewInputs: { + entries: Overlays.contextMenuEntries(factsOf(model), row.message.id), toTrigger: render, content: contextMenuContent(h, factsOf(model).isPinned(row.message.id)), className: "min-w-56", diff --git a/apps/web-foldkit/src/page/home/story.test.ts b/apps/web-foldkit/src/page/home/story.test.ts index 608638dec..373a1af6f 100644 --- a/apps/web-foldkit/src/page/home/story.test.ts +++ b/apps/web-foldkit/src/page/home/story.test.ts @@ -11,6 +11,7 @@ import { type DirectoryMember, DmRequest, type DmRow, filterMembers, type Model import { init, update } from "./update" import { sharedDefaults } from "../test-shared" import { failureToastFixture } from "../../test/pages-fixtures" +import { menuEntries } from "./model" // `ui/aria/interaction` names `document` when its module loads; the update loop never touches it. vi.hoisted(() => { @@ -100,7 +101,7 @@ describe("org home directory", () => { story( pageUpdate, given(withOpenMenu(grace)), - message(menuMessage(grace, Menu.Message.ClickedItem({ key: "copy-email" }))), + message(menuMessage(grace, Menu.Message.ClickedItem({ key: "copy-email", entries: menuEntries }))), model((current) => expect(current.menus[grace]?.popup._tag).toBe("Closed")), Command.resolve(CopyEmail, Message.SucceededCopyEmail({ email: "grace@hazel.test" })), expectOutMessage( @@ -177,7 +178,7 @@ describe("org home failures and guards", () => { story( pageUpdate, given(withOpenMenu(grace)), - message(menuMessage(grace, Menu.Message.ClickedItem({ key: "copy-email" }))), + message(menuMessage(grace, Menu.Message.ClickedItem({ key: "copy-email", entries: menuEntries }))), Command.resolve(CopyEmail({ email: "grace@hazel.test" }), Message.FailedCopyEmail()), expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "error", title: "Failed to copy email", description: "Please try again" } }), @@ -189,7 +190,7 @@ describe("org home failures and guards", () => { story( pageUpdate, given(withOpenMenu(grace)), - message(menuMessage(grace, Menu.Message.ClickedItem({ key: "message" }))), + message(menuMessage(grace, Menu.Message.ClickedItem({ key: "message", entries: menuEntries }))), Command.expectHas(CreateDm({ organizationId, userId: grace, name: "Grace Hopper" })), Command.resolve(CreateDm, Message.SucceededCreateDm({ channelId, name: "Grace Hopper" })), ) diff --git a/apps/web-foldkit/src/page/home/update.ts b/apps/web-foldkit/src/page/home/update.ts index fd632e14a..e99c38445 100644 --- a/apps/web-foldkit/src/page/home/update.ts +++ b/apps/web-foldkit/src/page/home/update.ts @@ -12,7 +12,7 @@ import { PageOutMessage, requestedOrgNavigation } from "../out-message" import { CopyEmail, CreateDm, FocusSearch } from "./commands" import { findExistingDmChannel } from "./dm" import { Message } from "./message" -import { type DirectoryMember, DmRequest, type DmRow, menuEntries, menuIdOf, type Model } from "./model" +import { type DirectoryMember, DmRequest, type DmRow, menuIdOf, type Model } from "./model" type Return = PageReturn<Model, Message> @@ -43,7 +43,7 @@ const reconcileMenus = (model: Model, members: ReadonlyArray<DirectoryMember>): members.map((member) => [ member.id, model.menus[member.id] ?? - Menu.init({ id: menuIdOf(member.id), entries: menuEntries, placement: "bottom end" }), + Menu.init({ id: menuIdOf(member.id), placement: "bottom end" }), ]), ) diff --git a/apps/web-foldkit/src/page/home/view.ts b/apps/web-foldkit/src/page/home/view.ts index 983cbf668..97273d88a 100644 --- a/apps/web-foldkit/src/page/home/view.ts +++ b/apps/web-foldkit/src/page/home/view.ts @@ -19,7 +19,7 @@ import { import type { PageViewInputs } from "../contract" import { AutoFocusSearch } from "./commands" import { Message } from "./message" -import { type DirectoryMember, filterMembers, menuIdOf, type Model, SEARCH_ID } from "./model" +import { type DirectoryMember, filterMembers, menuEntries, menuIdOf, type Model, SEARCH_ID } from "./model" /** Port of `routes/_app/$orgSlug/index.tsx`. */ @@ -76,6 +76,7 @@ const memberMenu = (h: HtmlBuilder<Message>, member: DirectoryMember, menu: Menu model: menu, view: menuView, viewInputs: { + entries: menuEntries, toTrigger: memberMenuTrigger(h), content: menuContent(h, menu.id), }, diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts index 8051bf55d..0ff5db30c 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/parts.ts @@ -7,6 +7,7 @@ import { type ChannelLink, DIRECTION_LABELS, linkMenuId, + linkMenuEntries, Message, type SyncDirection, type WebhookPermission, @@ -141,6 +142,7 @@ export const channelLinkRow = ( model: menu, view: menuView, viewInputs: { + entries: linkMenuEntries, toTrigger: ( attributes: ReadonlyArray<ChildAttribute>, overlay: Html, diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts index 490f5efc1..a0a429e44 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts @@ -25,6 +25,7 @@ import { import { Message } from "./model" import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" +import { linkMenuEntries } from "./model" const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` const organizationId = Schema.decodeSync(OrganizationId)(uuid(1)) @@ -74,7 +75,7 @@ describe("chat sync connection", () => { ), Command.resolveAll(), message( - Message.GotLinkMenuMessage({ linkId, message: Menu.Message.ClickedItem({ key: "remove" }) }), + Message.GotLinkMenuMessage({ linkId, message: Menu.Message.ClickedItem({ key: "remove", entries: linkMenuEntries }) }), ), model((current) => { expect(current.deleteTarget).toEqual({ id: linkId, name: "general" }) @@ -232,7 +233,7 @@ describe("chat sync connection guards and link actions", () => { message: Menu.Message.PressedTrigger({ pointerType: "mouse" }), }) const pick = (key: string) => - Message.GotLinkMenuMessage({ linkId, message: Menu.Message.ClickedItem({ key }) }) + Message.GotLinkMenuMessage({ linkId, message: Menu.Message.ClickedItem({ key, entries: linkMenuEntries }) }) test("Pause sync from the row menu pauses the link, toasts and reloads", () => { story( diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts index 1fab85984..9be716fdb 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts @@ -23,7 +23,6 @@ import { import { ADD_LINK_MODAL_ID, type ChannelLink, - linkMenuEntries, linkMenuId, Message, type Model, @@ -118,12 +117,7 @@ const reflectLinkMenus = (model: Model, links: ReadonlyArray<ChannelLink>): Mode links.map((link) => Option.getOrElse( Array.findFirst(menus, (menu) => menu.id === linkMenuId(link.id)), - () => - Menu.init({ - id: linkMenuId(link.id), - entries: linkMenuEntries, - placement: "bottom end", - }), + () => Menu.init({ id: linkMenuId(link.id), placement: "bottom end" }), ), ), }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/update.ts b/apps/web-foldkit/src/page/settings/chat-sync/update.ts index 64aafc06f..accc5b855 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/update.ts @@ -10,7 +10,7 @@ import * as Modal from "../../../ui/modal" import { failureToast, successToast } from "../../../data/actions" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage, requestedOrgNavigation } from "../../out-message" -import { addMenuEntries, Message, type Model } from "./model" +import { Message, type Model } from "./model" import { fetchDiscordGuilds } from "./discord" import { fetchConnections } from "./rpc" import { ADD_CONNECTION_MODAL_ID, CreateConnection, FocusGuildSearch } from "./add-connection" @@ -93,12 +93,8 @@ export const init = (_route: unknown, shared: Shared): Return => { requestedOrganizationId: null, connections: { _tag: "Loading" }, - addMenu: Menu.init({ id: "chat-sync-add", entries: addMenuEntries, placement: "bottom end" }), - emptyAddMenu: Menu.init({ - id: "chat-sync-add-empty", - entries: addMenuEntries, - placement: "bottom end", - }), + addMenu: Menu.init({ id: "chat-sync-add", placement: "bottom end" }), + emptyAddMenu: Menu.init({ id: "chat-sync-add-empty", placement: "bottom end" }), addModal: Modal.init(ADD_CONNECTION_MODAL_ID), discordGuilds: { _tag: "Loading" }, guildsVersion: 0, diff --git a/apps/web-foldkit/src/page/settings/chat-sync/view.ts b/apps/web-foldkit/src/page/settings/chat-sync/view.ts index 908b04ae4..32b87ee76 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/view.ts @@ -19,7 +19,7 @@ import { discordLogo, slackLogo } from "./brand-icons" import { addConnectionModal } from "./add-connection-view" import { confirmDialog } from "./confirm-dialog" import { formatSyncedAt } from "./rpc" -import { type Connection, Message, type Model, STATUS_CONFIG } from "./model" +import { addMenuEntries, type Connection, Message, type Model, STATUS_CONFIG } from "./model" /** Port of `settings/chat-sync/index.tsx`. */ @@ -66,6 +66,7 @@ const addConnectionDropdown = ( model: menu, view: menuView, viewInputs: { + entries: addMenuEntries, toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => h.button( [ diff --git a/apps/web-foldkit/src/page/settings/integrations/shared/bot-card.ts b/apps/web-foldkit/src/page/settings/integrations/shared/bot-card.ts index a099bd412..35b014259 100644 --- a/apps/web-foldkit/src/page/settings/integrations/shared/bot-card.ts +++ b/apps/web-foldkit/src/page/settings/integrations/shared/bot-card.ts @@ -118,6 +118,7 @@ const actionsMenu = <Message>( model: menu, view: menuView, viewInputs: { + entries: BOT_MENU_ENTRIES, // MenuTrigger renders a button around the Button and hands both the trigger props. toTrigger: (attributes, overlay) => h.button( diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts index de9681dd4..1410544f5 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts @@ -8,6 +8,7 @@ import type { Bot } from "../shared/bots" import { PageOutMessage } from "../../../out-message" import { Message } from "./message" import { init, update } from "./update" +import { BOT_MENU_ENTRIES } from "../shared/bot-card" const bot = (n: number, name: string): Bot => ({ id: Schema.decodeSync(BotId)(`00000000-0000-4000-8000-00000000000${n}`), @@ -53,7 +54,7 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: deploy.id, message: Menu.Message.ClickedItem({ key: "regenerate" }) })), + message(Message.GotMenuMessage({ botId: deploy.id, message: Menu.Message.ClickedItem({ key: "regenerate", entries: BOT_MENU_ENTRIES }) })), expectOutMessage( PageOutMessage.RequestedModal({ modal: { _tag: "RegenerateBotToken", botId: deploy.id, botName: "Deploy Bot" } }), ), @@ -65,7 +66,7 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: triage.id, message: Menu.Message.ClickedItem({ key: "delete" }) })), + message(Message.GotMenuMessage({ botId: triage.id, message: Menu.Message.ClickedItem({ key: "delete", entries: BOT_MENU_ENTRIES }) })), expectNoOutMessage(), model((current) => expect(current).toEqual(withOpenMenu)), ) diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts index 49651b73d..9b1df7c58 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts @@ -5,7 +5,7 @@ import * as Menu from "../../../../ui/menu" import type { ModalRequest } from "../../../../overlay/modal/requests" import type { PageReturn } from "../../../contract" import { PageOutMessage } from "../../../out-message" -import { BOT_MENU_ENTRIES, botMenuId } from "../shared/bot-card" +import { botMenuId } from "../shared/bot-card" import type { Bot } from "../shared/bots" import { Message } from "./message" import type { Model } from "./model" @@ -19,7 +19,7 @@ const menusFor = (bots: ReadonlyArray<Bot>, previous: ReadonlyArray<Menu.Model>) bots.map((bot) => Option.getOrElse( Array.findFirst(previous, (menu) => menu.id === botMenuId(bot.id)), - () => Menu.init({ id: botMenuId(bot.id), entries: BOT_MENU_ENTRIES, placement: "bottom end" }), + () => Menu.init({ id: botMenuId(bot.id), placement: "bottom end" }), ), ) diff --git a/apps/web-foldkit/src/page/settings/invitations/page.test.ts b/apps/web-foldkit/src/page/settings/invitations/page.test.ts index 3392eae11..fb2cbad83 100644 --- a/apps/web-foldkit/src/page/settings/invitations/page.test.ts +++ b/apps/web-foldkit/src/page/settings/invitations/page.test.ts @@ -7,9 +7,9 @@ import { makeShared, uuid } from "../../../test/pages-fixtures" import { Message } from "./message" import type { Invitation } from "./model" import { init, REVOKE_KEY, update } from "./update" +import { rowMenuEntries } from "./update" const inv1 = Schema.decodeSync(InvitationId)(uuid(11)) -const inv2 = Schema.decodeSync(InvitationId)(uuid(12)) /** Update-loop tests for pending invitations: row menus and the revoke lifecycle. */ @@ -21,7 +21,7 @@ describe("pending invitations", () => { test("every invitation gets a row menu with a revoke item", () => { const model = loaded() expect(model.menus).toHaveLength(1) - expect(model.menus[0]?.menu.entries).toEqual([ + expect(rowMenuEntries(model.revokingId === invitation.id)).toEqual([ Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: false }), ]) }) diff --git a/apps/web-foldkit/src/page/settings/invitations/story.test.ts b/apps/web-foldkit/src/page/settings/invitations/story.test.ts index a5341ecf1..86b5cab24 100644 --- a/apps/web-foldkit/src/page/settings/invitations/story.test.ts +++ b/apps/web-foldkit/src/page/settings/invitations/story.test.ts @@ -10,6 +10,7 @@ import { PageOutMessage } from "../../out-message" import { Message } from "./message" import type { Invitation } from "./model" import { FetchInvitations, init, REVOKE_KEY, RevokeInvitation, update } from "./update" +import { rowMenuEntries } from "./update" const inv1 = Schema.decodeSync(InvitationId)(uuid(11)) const inv2 = Schema.decodeSync(InvitationId)(uuid(12)) @@ -29,11 +30,13 @@ const rowMenu = (invitationId: InvitationId, child: Menu.Message) => const openMenu = (invitationId: InvitationId) => message(rowMenu(invitationId, Menu.Message.PressedTrigger({ pointerType: "mouse" }))) const pickRevoke = (invitationId: InvitationId) => - message(rowMenu(invitationId, Menu.Message.ClickedItem({ key: REVOKE_KEY }))) + message(rowMenu(invitationId, Menu.Message.ClickedItem({ key: REVOKE_KEY, entries: rowMenuEntries(false) }))) const revokeDisabled = (invitationId: InvitationId) => model<typeof loaded>((current) => { - const row = current.menus.find((candidate) => candidate.invitationId === invitationId) - expect(row?.menu.entries).toEqual([Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: true })]) + expect(current.revokingId).toBe(invitationId) + expect(rowMenuEntries(current.revokingId === invitationId)).toEqual([ + Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: true }), + ]) }) describe("fetch", () => { @@ -122,7 +125,7 @@ describe("revoke", () => { ), model((current) => { expect(current.revokingId).toBeNull() - expect(current.menus[0]?.menu.entries).toEqual([ + expect(rowMenuEntries(current.revokingId !== null)).toEqual([ Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: false }), ]) }), diff --git a/apps/web-foldkit/src/page/settings/invitations/update.ts b/apps/web-foldkit/src/page/settings/invitations/update.ts index 2f6d2bc57..47d3cebd7 100644 --- a/apps/web-foldkit/src/page/settings/invitations/update.ts +++ b/apps/web-foldkit/src/page/settings/invitations/update.ts @@ -42,38 +42,24 @@ export const RevokeInvitation = Command.define("RevokeInvitation", { // MENUS -const menuEntries = (isRevoking: boolean) => [ +export const rowMenuEntries = (isRevoking: boolean): ReadonlyArray<Menu.Entry> => [ Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: isRevoking }), ] /** One row menu per invitation, keeping open state for rows that stay. */ -const reflectMenus = ( - menus: ReadonlyArray<RowMenu>, - invitations: ReadonlyArray<Invitation>, - revokingId: InvitationId | null, -): ReadonlyArray<RowMenu> => - invitations.map((invitation) => { - const entries = menuEntries(revokingId === invitation.id) - return Option.match( +const reflectMenus = (menus: ReadonlyArray<RowMenu>, invitations: ReadonlyArray<Invitation>): ReadonlyArray<RowMenu> => + invitations.map((invitation) => + Option.getOrElse( Array.findFirst(menus, (row) => row.invitationId === invitation.id), - { - onNone: () => ({ - invitationId: invitation.id, - menu: Menu.init({ id: `invitation-${invitation.id}`, entries, placement: "bottom end" }), - }), - onSome: (row) => ({ - invitationId: row.invitationId, - menu: Menu.reflectEntries(row.menu, entries), - }), - }, - ) - }) + () => ({ + invitationId: invitation.id, + menu: Menu.init({ id: `invitation-${invitation.id}`, placement: "bottom end" }), + }), + ), + ) const withRevokingId = (model: Model, revokingId: InvitationId | null): Model => - modifyFields(model, { - revokingId: () => revokingId, - menus: (menus) => reflectMenus(menus, model.invitations, revokingId), - }) + modifyFields(model, { revokingId: () => revokingId }) // INIT @@ -135,7 +121,7 @@ export const update = (model: Model, message: Message): Return => : { model: modifyFields(model, { invitations: () => invitations, - menus: (menus) => reflectMenus(menus, invitations, model.revokingId), + menus: (menus) => reflectMenus(menus, invitations), }), }, ClickedInviteUser: () => ({ diff --git a/apps/web-foldkit/src/page/settings/invitations/view.ts b/apps/web-foldkit/src/page/settings/invitations/view.ts index 387747974..64ac99de1 100644 --- a/apps/web-foldkit/src/page/settings/invitations/view.ts +++ b/apps/web-foldkit/src/page/settings/invitations/view.ts @@ -10,6 +10,7 @@ import { menuLabel, view as menuView } from "../../../ui/menu-view" import type { PageViewInputs } from "../../contract" import { Message } from "./message" import type { Invitation, Model, RowMenu } from "./model" +import { rowMenuEntries } from "./update" /** Port of `routes/_app/$orgSlug/settings/invitations.tsx`. */ @@ -34,6 +35,7 @@ const rowActions = (h: HtmlBuilder<Message>, row: RowMenu, isRevoking: boolean): model: row.menu, view: menuView, viewInputs: { + entries: rowMenuEntries(isRevoking), toTrigger: (attributes, overlay) => button(h, { intent: "plain", size: "sq-xs", isPending: isRevoking, isDisabled: isRevoking, attributes }, [ IconDots(h), diff --git a/apps/web-foldkit/src/shell/channels-sidebar.ts b/apps/web-foldkit/src/shell/channels-sidebar.ts index 706b828c8..daf9214ad 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar.ts @@ -2,7 +2,6 @@ import type { OrganizationId, UserId } from "@hazel/schema" import { Effect } from "effect" import { getEffectivePresenceStatus } from "~/utils/presence" import { Mount } from "foldkit" -import type { Update } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { modifyFields } from "foldkit/struct" import type * as Menu from "../ui/menu" @@ -21,7 +20,7 @@ import { updateRowMenu, updateSectionMenu, } from "./channels-sidebar/menu-update" -import { sectionMenuView } from "./channels-sidebar/menus" +import { sectionMenuEntries, sectionMenuView } from "./channels-sidebar/menus" import { dragDescription, sectionPlusButton } from "./channels-sidebar/tree" import { sectionGroupContent } from "./channels-sidebar/view" @@ -169,6 +168,8 @@ const ScrollActiveIntoView = Mount.define("ScrollActiveIntoView", { export interface SidebarChrome<ParentMessage> { readonly userMenu: Menu.Model readonly orgSwitcher: Menu.Model + readonly userMenuEntries: ReadonlyArray<Menu.Entry> + readonly orgSwitcherEntries: ReadonlyArray<Menu.Entry> readonly organizations: ReadonlyArray<SwitcherOrg> readonly userStatus: UserStatus | null /** The status's "Until ..." label at the root's clock (a string, so the memo stays cheap). */ @@ -202,6 +203,8 @@ const sidebarBody = <ParentMessage>( toParentMessage: (message: Message) => ParentMessage, userMenu: Menu.Model, orgSwitcher: Menu.Model, + userMenuEntries: ReadonlyArray<Menu.Entry>, + orgSwitcherEntries: ReadonlyArray<Menu.Entry>, organizations: ReadonlyArray<SwitcherOrg>, userStatus: UserStatus | null, statusExpiration: string | null, @@ -220,7 +223,7 @@ const sidebarBody = <ParentMessage>( appVersion, } return sidebarStatic(h, "flex flex-1", [ - orgSwitcherHeader(h, orgSwitcher, shell, organizations, toOrgSwitcherMessage), + orgSwitcherHeader(h, orgSwitcher, orgSwitcherEntries, shell, organizations, toOrgSwitcherMessage), sidebarContent(h, { state: "expanded" }, [ sidebarSectionGroup( h, @@ -238,7 +241,7 @@ const sidebarBody = <ParentMessage>( }), ), ]), - userMenuFooter(h, userMenu, shell, toUserMenuMessage, userStatus, statusExpiration), + userMenuFooter(h, userMenu, userMenuEntries, shell, toUserMenuMessage, userStatus, statusExpiration), dragDescription(h), ]) } @@ -256,6 +259,7 @@ const sectionActionView = <ParentMessage>( ? sectionPlusButton(h, [h.OnClick(toParentMessage(Message.ClickedSectionAction({ action: only })))]) : sectionMenuView(h, { menu: sectionMenuOf(model, sectionKey), + entries: sectionMenuEntries(actions, isEditable), toMessage: (message) => toParentMessage(Message.GotSectionMenuMessage({ sectionId: sectionKey, message })), }) @@ -281,6 +285,8 @@ export const view = <ParentMessage>( toParentMessage, chrome.userMenu, chrome.orgSwitcher, + chrome.userMenuEntries, + chrome.orgSwitcherEntries, chrome.organizations, chrome.userStatus, chrome.statusExpiration, diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts b/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts index 589a11bd6..3c6ac9beb 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts @@ -10,7 +10,7 @@ import { AppRoute, hrefOf } from "../../route" import type { HazelRpc } from "../../rpc" import * as Menu from "../../ui/menu" import { DeleteChannelSection, LeaveChannel, MoveChannelToSection, UpdateChannelMember } from "./actions" -import { closedMenu, closedRowMenu, sectionIdOfKey, sectionMenuEntries, sectionMenuId } from "./menus" +import { closedMenu, closedRowMenu, sectionIdOfKey, sectionMenuId } from "./menus" import { Message, type Model, type SectionAction } from "./model" import type { ChannelEntry } from "./rows" @@ -58,15 +58,10 @@ export const menuFor = (model: Model, target: string, build: () => Menu.Model): model.openMenu !== null && model.openMenu.target === target ? model.openMenu.menu : build() export const rowMenuOf = (model: Model, channelId: ChannelId) => - menuFor(model, `channel:${channelId}`, () => - closedRowMenu(channelId, model.sections, canOn(model, "channel.delete")), - ) + menuFor(model, `channel:${channelId}`, () => closedRowMenu(channelId)) export const sectionMenuOf = (model: Model, sectionKey: string) => - menuFor(model, `section:${sectionKey}`, () => { - const { actions, isEditable } = sectionActionsOf(model, sectionKey) - return closedMenu(sectionMenuId(sectionKey), sectionMenuEntries(actions, isEditable)) - }) + menuFor(model, `section:${sectionKey}`, () => closedMenu(sectionMenuId(sectionKey))) /** Folds a Menu step; the menu is kept only while open. */ const foldMenu = ( diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts index 52b57bcc6..4a2d2934d 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts @@ -77,12 +77,11 @@ export const sectionMenuEntries = ( ...(isEditable ? [Menu.separator, Menu.item("delete-section", { intent: "Danger" })] : []), ] -export const closedMenu = (id: string, entries: ReadonlyArray<Menu.Entry>, placement?: "right top") => - Menu.init({ id, entries, ...(placement ? { placement } : {}) }) +export const closedMenu = (id: string, placement?: "right top") => + Menu.init({ id, ...(placement ? { placement } : {}) }) /** A channel row's dots menu while it is closed. */ -export const closedRowMenu = (channelId: string, sections: ReadonlyArray<Section>, canDelete: boolean) => - closedMenu(rowMenuId(channelId), rowMenuEntries(sections, canDelete), "right top") +export const closedRowMenu = (channelId: string) => closedMenu(rowMenuId(channelId), "right top") // VIEW @@ -122,6 +121,7 @@ export const rowMenuView = <M>( menu: Menu.Model entry: ChannelEntry sections: ReadonlyArray<Section> + canDelete: boolean toMessage: (message: Menu.Message) => M }>, ): Html => @@ -130,6 +130,7 @@ export const rowMenuView = <M>( model: options.menu, view: menuView, viewInputs: { + entries: rowMenuEntries(options.sections, options.canDelete), toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => button( h, @@ -163,13 +164,18 @@ const sectionMenuContent = /** `SectionGroup`'s "+" `<Menu>` (several actions or a deletable section). */ export const sectionMenuView = <M>( h: HtmlBuilder<M>, - options: Readonly<{ menu: Menu.Model; toMessage: (message: Menu.Message) => M }>, + options: Readonly<{ + menu: Menu.Model + entries: ReadonlyArray<Menu.Entry> + toMessage: (message: Menu.Message) => M + }>, ): Html => h.submodel({ slotId: options.menu.id, model: options.menu, view: menuView, viewInputs: { + entries: options.entries, toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => button(h, { intent: "plain", isCircle: true, size: "sq-xs", attributes: [...attributes] }, [ IconPlus(h), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/view.ts b/apps/web-foldkit/src/shell/channels-sidebar/view.ts index 7eff5b04d..73761c514 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/view.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/view.ts @@ -96,7 +96,8 @@ const channelRowView = <M>( partners, { orgSlug, isActive: isLinkActive }, rowMenuView(h, { - menu: openMenu ?? closedRowMenu(entry.channel.id, sections, canDelete), + menu: openMenu ?? closedRowMenu(entry.channel.id), + canDelete, entry, sections, toMessage: (message) => diff --git a/apps/web-foldkit/src/shell/menus.ts b/apps/web-foldkit/src/shell/menus.ts index ca41fad92..8295f9be0 100644 --- a/apps/web-foldkit/src/shell/menus.ts +++ b/apps/web-foldkit/src/shell/menus.ts @@ -182,6 +182,7 @@ const userMenuContent = export const userMenuFooter = <M>( h: HtmlBuilder<M>, menu: Menu.Model, + entries: ReadonlyArray<Menu.Entry>, context: ShellContext, toMenuMessage: (message: Menu.Message) => M, status: UserStatus | null, @@ -195,6 +196,7 @@ export const userMenuFooter = <M>( model: menu, view: menuView, viewInputs: { + entries, toTrigger: (attributes, overlay) => h.button( [ @@ -293,6 +295,7 @@ const switcherContent = export const orgSwitcherHeader = <M>( h: HtmlBuilder<M>, menu: Menu.Model, + entries: ReadonlyArray<Menu.Entry>, context: ShellContext, organizations: ReadonlyArray<SwitcherOrg>, toMenuMessage: (message: Menu.Message) => M, @@ -303,6 +306,7 @@ export const orgSwitcherHeader = <M>( model: menu, view: menuView, viewInputs: { + entries, toTrigger: (attributes, overlay) => h.button( [ diff --git a/apps/web-foldkit/src/shell/model.ts b/apps/web-foldkit/src/shell/model.ts index aa4a973b3..bf58bd0d4 100644 --- a/apps/web-foldkit/src/shell/model.ts +++ b/apps/web-foldkit/src/shell/model.ts @@ -36,7 +36,13 @@ export const Model = Schema.Struct({ channelsSidebar: ChannelsSidebar.Model, userMenu: Menu.Model, orgSwitcher: Menu.Model, - /** The inputs the menus' entries were last built from; rebuilt only when this changes. */ + /** + * The menus' structure, which the views pass to the menus. Kept here (not rebuilt per render) so the + * memoized sidebars see a stable reference; rebuilt only when `menuSignature` changes. + */ + userMenuEntries: Schema.Array(Menu.Entry), + orgSwitcherEntries: Schema.Array(Menu.Entry), + /** The inputs the menus' entries were last built from. */ menuSignature: Schema.String, userOrganizations: Schema.Array(SwitcherOrg), userStatus: Schema.NullOr(UserStatus), diff --git a/apps/web-foldkit/src/shell/shell.story.test.ts b/apps/web-foldkit/src/shell/shell.story.test.ts index ff65ed197..12a439c10 100644 --- a/apps/web-foldkit/src/shell/shell.story.test.ts +++ b/apps/web-foldkit/src/shell/shell.story.test.ts @@ -25,7 +25,8 @@ const ready = informContext(init(), context).model const userMenu = (inner: Menu.Message) => Message.GotUserMenuMessage({ message: inner }) const orgSwitcher = (inner: Menu.Message) => Message.GotOrgSwitcherMessage({ message: inner }) const opened = Menu.Message.PressedTrigger({ pointerType: "mouse" }) -const clicked = (key: string) => Menu.Message.ClickedItem({ key }) +/** A click on `key`, with the entries the view passes the menu (the shell Model's, as `app/view.ts` does). */ +const clicked = (key: string, entries: ReadonlyArray<Menu.Entry>) => Menu.Message.ClickedItem({ key, entries }) const otherOrgId = Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-0000000000a2") const unfinishedOrgId = Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-0000000000a3") @@ -37,7 +38,7 @@ const organizations = [ describe("user menu", () => { test("its links follow the route's org and the signed-in user", () => { - const hrefs = ready.userMenu.entries.flatMap((entry) => + const hrefs = ready.userMenuEntries.flatMap((entry) => entry._tag === "Item" && entry.item.href._tag === "Some" ? [entry.item.href.value] : [], ) expect(hrefs).toEqual([`/hazel/profile/${ada.id}`, "/hazel/my-settings"]) @@ -52,7 +53,7 @@ describe("user menu", () => { run, given(ready), message(userMenu(opened)), - message(userMenu(clicked(key))), + message(userMenu(clicked(key, ready.userMenuEntries))), Command.expectNone(), expectOutMessage(outMessage), ) @@ -60,12 +61,15 @@ describe("user menu", () => { }) describe("org switcher", () => { + const withOrganizations = run(ready, Message.UpdatedUserOrganizations({ organizations })).model + const switcherEntries = withOrganizations.orgSwitcherEntries + test("Invite people opens the email invite modal", () => { story( run, given(ready), message(orgSwitcher(opened)), - message(orgSwitcher(clicked("invite-people"))), + message(orgSwitcher(clicked("invite-people", ready.orgSwitcherEntries))), expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "EmailInvite" } })), ) }) @@ -73,10 +77,10 @@ describe("org switcher", () => { test("on desktop, an organization in the Switch server submenu navigates to its slug", () => { story( run, - given(run(ready, Message.UpdatedUserOrganizations({ organizations })).model), + given(withOrganizations), message(orgSwitcher(opened)), - message(orgSwitcher(clicked("switch-server"))), - message(orgSwitcher(clicked(`org:${otherOrgId}`))), + message(orgSwitcher(clicked("switch-server", switcherEntries))), + message(orgSwitcher(clicked(`org:${otherOrgId}`, switcherEntries))), expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/acme", replace: false })), ) }) @@ -84,10 +88,10 @@ describe("org switcher", () => { test("on desktop, the current organization in the submenu does nothing (legacy `SwitchServerMenu`)", () => { story( run, - given(run(ready, Message.UpdatedUserOrganizations({ organizations })).model), + given(withOrganizations), message(orgSwitcher(opened)), - message(orgSwitcher(clicked("switch-server"))), - message(orgSwitcher(clicked(`org:${hazelOrg.id}`))), + message(orgSwitcher(clicked("switch-server", switcherEntries))), + message(orgSwitcher(clicked(`org:${hazelOrg.id}`, switcherEntries))), expectNoOutMessage(), ) }) @@ -109,7 +113,7 @@ describe("org switcher", () => { run, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${otherOrgId}`))), + message(orgSwitcher(clicked(`org:${otherOrgId}`, mobile.orgSwitcherEntries))), expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/acme", replace: false })), ) }) @@ -119,7 +123,7 @@ describe("org switcher", () => { run, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${unfinishedOrgId}`))), + message(orgSwitcher(clicked(`org:${unfinishedOrgId}`, mobile.orgSwitcherEntries))), expectOutMessage( PageOutMessage.RequestedNavigation({ href: `/onboarding/setup-organization?orgId=${unfinishedOrgId}`, @@ -134,7 +138,7 @@ describe("org switcher", () => { run, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${hazelOrg.id}`))), + message(orgSwitcher(clicked(`org:${hazelOrg.id}`, mobile.orgSwitcherEntries))), expectNoOutMessage(), ) }) @@ -158,7 +162,7 @@ describe("context", () => { test("switching organization rebuilds the menus with the new slug", () => { const acme = informContext(ready, { ...context, orgSlug: "acme", organizationId: otherOrgId }).model expect(acme.menuSignature).not.toBe(ready.menuSignature) - const mySettings = acme.userMenu.entries.flatMap((entry) => + const mySettings = acme.userMenuEntries.flatMap((entry) => entry._tag === "Item" && entry.item.key === "my-settings" && entry.item.href._tag === "Some" ? [entry.item.href.value] : [], diff --git a/apps/web-foldkit/src/shell/sidebars.ts b/apps/web-foldkit/src/shell/sidebars.ts index d8440d1b3..a9abb0e4d 100644 --- a/apps/web-foldkit/src/shell/sidebars.ts +++ b/apps/web-foldkit/src/shell/sidebars.ts @@ -91,7 +91,14 @@ export const settingsSidebar = <Message>( (isActiveFuzzy(path, integrations) && !integrationSubRoutes.some((sub) => isActiveFuzzy(path, sub))) return sidebarStatic(h, "flex flex-1", [ - orgSwitcherHeader(h, chrome.orgSwitcher, context, chrome.organizations, chrome.toOrgSwitcherMessage), + orgSwitcherHeader( + h, + chrome.orgSwitcher, + chrome.orgSwitcherEntries, + context, + chrome.organizations, + chrome.toOrgSwitcherMessage, + ), sidebarContent(h, { state: "expanded" }, [ sidebarSectionGroup(h, [ sectionTitle(h, "Settings"), @@ -136,6 +143,7 @@ export const settingsSidebar = <Message>( userMenuFooter( h, chrome.userMenu, + chrome.userMenuEntries, context, chrome.toUserMenuMessage, chrome.userStatus, @@ -163,7 +171,14 @@ export const mySettingsSidebar = <Message>( isLinkActive: isActiveFuzzy(path, to), }) return sidebarStatic(h, "flex flex-1", [ - orgSwitcherHeader(h, chrome.orgSwitcher, context, chrome.organizations, chrome.toOrgSwitcherMessage), + orgSwitcherHeader( + h, + chrome.orgSwitcher, + chrome.orgSwitcherEntries, + context, + chrome.organizations, + chrome.toOrgSwitcherMessage, + ), sidebarContent(h, { state: "expanded" }, [ sidebarSectionGroup(h, [ sectionTitle(h, "My Settings"), @@ -184,6 +199,7 @@ export const mySettingsSidebar = <Message>( userMenuFooter( h, chrome.userMenu, + chrome.userMenuEntries, context, chrome.toUserMenuMessage, chrome.userStatus, @@ -250,6 +266,7 @@ export const notificationsSidebar = <Message>( userMenuFooter( h, chrome.userMenu, + chrome.userMenuEntries, context, chrome.toUserMenuMessage, chrome.userStatus, diff --git a/apps/web-foldkit/src/shell/update.ts b/apps/web-foldkit/src/shell/update.ts index 4e88e1cdd..a8acb8887 100644 --- a/apps/web-foldkit/src/shell/update.ts +++ b/apps/web-foldkit/src/shell/update.ts @@ -28,8 +28,10 @@ export interface Context { export const init = (): Model => ({ channelsSidebar: ChannelsSidebar.init(), - userMenu: Menu.init({ id: USER_MENU_ID, entries: [], placement: "bottom right" }), - orgSwitcher: Menu.init({ id: ORG_SWITCHER_ID, entries: [] }), + userMenu: Menu.init({ id: USER_MENU_ID, placement: "bottom right" }), + orgSwitcher: Menu.init({ id: ORG_SWITCHER_ID }), + userMenuEntries: [], + orgSwitcherEntries: [], menuSignature: "", userOrganizations: [], userStatus: null, @@ -62,22 +64,19 @@ const withMenuEntries = (model: Model, context: Context): Model => { if (signature === model.menuSignature) return model return modifyFields(model, { menuSignature: () => signature, - userMenu: (menu) => - currentUserId === null - ? menu - : Menu.reflectEntries(menu, userMenuEntries(orgSlug, currentUserId)), + userMenuEntries: (entries) => + currentUserId === null ? entries : userMenuEntries(orgSlug, currentUserId), + orgSwitcherEntries: () => + orgSwitcherEntries({ + orgSlug, + isChat: context.isChatSection, + isMobile: model.isMobile, + canCreateChannel: context.canCreateChannel, + organizations: model.userOrganizations, + }), orgSwitcher: (menu) => modifyFields( - Menu.reflectEntries( - menu, - orgSwitcherEntries({ - orgSlug, - isChat: context.isChatSection, - isMobile: model.isMobile, - canCreateChannel: context.canCreateChannel, - organizations: model.userOrganizations, - }), - ), + menu, // Mobile shows `SwitchServerMenu` alone: a single-selection list with the current org checked. { selectionMode: () => (model.isMobile ? "Single" : "None"), diff --git a/apps/web-foldkit/src/ui/menu-view.ts b/apps/web-foldkit/src/ui/menu-view.ts index 3f6c39dbf..f52490ca1 100644 --- a/apps/web-foldkit/src/ui/menu-view.ts +++ b/apps/web-foldkit/src/ui/menu-view.ts @@ -209,6 +209,8 @@ export const menuTriggerClassName = (className?: string) => twMerge(twMerge(...m // VIEW export type ViewInputs = Readonly<{ + /** The menu's structure, owned by the parent (build it with `item`, `section` and `separator`). */ + entries: ReadonlyArray<Entry> /** Renders the trigger. Spread `attributes` on it and put `overlay` last among its children. */ toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html /** The children of one item (icon, label, shortcut, description), or of a section's header. */ @@ -243,7 +245,7 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view ), h.OnKeyDownPreventDefault((key) => Array.contains(["Enter", " ", "ArrowDown", "ArrowUp"], key) - ? Option.some(Message.PressedTriggerKey({ key })) + ? Option.some(Message.PressedTriggerKey({ key, entries: viewInputs.entries })) : Option.none(), ), ]) @@ -283,6 +285,7 @@ const overlay = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde Message.PressedMenuKey({ key, isModified: modifiers.ctrlKey || modifiers.metaKey || modifiers.altKey, + entries: viewInputs.entries, }) return h.div( @@ -341,7 +344,7 @@ const overlay = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde className: viewInputs.className, elementId: menuId(model.id), labelledBy: isAtPointer ? undefined : triggerId(model.id), - entries: model.entries, + entries: viewInputs.entries, header: typeof viewInputs.header === "function" ? viewInputs.header() @@ -382,7 +385,7 @@ const submenuPopover = ( triggerKey: string, focusedKey: Option.Option<string>, ): Html => { - const trigger = Array.findFirst(rootItems(model), (candidate) => candidate.key === triggerKey) + const trigger = Array.findFirst(rootItems(viewInputs.entries), (candidate) => candidate.key === triggerKey) const leaves = Option.match(trigger, { onNone: () => [], onSome: (found) => found.submenu }) return h.div( [ @@ -582,9 +585,9 @@ const menuItem = ( h.Id(itemId(model.id, key)), h.Role(isSelectable ? "menuitemradio" : "menuitem"), ...(entry.isDisabled ? [] : [h.Attribute("tabindex", isFocused ? "0" : "-1")]), - h.OnMouseEnter(Message.HoveredItem({ key })), + h.OnMouseEnter(Message.HoveredItem({ key, entries: viewInputs.entries })), h.OnMouseLeave(Message.UnhoveredItem({ key })), - h.OnClick(Message.ClickedItem({ key })), + h.OnClick(Message.ClickedItem({ key, entries: viewInputs.entries })), ], [ ...(isSelected diff --git a/apps/web-foldkit/src/ui/menu.scene.test.ts b/apps/web-foldkit/src/ui/menu.scene.test.ts index bcc22fa14..b56f0e6f8 100644 --- a/apps/web-foldkit/src/ui/menu.scene.test.ts +++ b/apps/web-foldkit/src/ui/menu.scene.test.ts @@ -15,17 +15,18 @@ const labels: Readonly<Record<string, string>> = { email: "Email", slack: "Slack", } -const viewInputs = { - toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => - ih.button([...attributes, ih.Attribute("aria-label", "File")], ["File", overlay]), - content: (key: string) => [menuLabel(ih, "file", key, labels[key] ?? key)], -} const entries = [ item("new", { textValue: "New file" }), item("open", { textValue: "Open file" }), item("archive", { isDisabled: true }), item("share", { submenu: [leaf("email"), leaf("slack")] }), ] +const viewInputs = { + entries, + toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => + ih.button([...attributes, ih.Attribute("aria-label", "File")], ["File", overlay]), + content: (key: string) => [menuLabel(ih, "file", key, labels[key] ?? key)], +} const config = { update, view: Scene.withViewInputs(view, viewInputs)() } const trigger = Scene.role("button", { name: "File" }) const menuItem = (name: string) => Scene.role("menuitem", { name }) @@ -38,7 +39,7 @@ describe("menu scene", () => { test("a closed trigger advertises its popup and renders no menu", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.expect(trigger).toHaveAttr("aria-haspopup", "true"), Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), @@ -50,7 +51,7 @@ describe("menu scene", () => { test("pressing the trigger opens a menu labelled by it with every item", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.pointerDown(trigger), portalled, @@ -66,7 +67,7 @@ describe("menu scene", () => { test("a right-button press on the trigger is ignored", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.pointerDown(trigger, { button: 2 }), Scene.expectIgnored(), @@ -77,7 +78,7 @@ describe("menu scene", () => { test("ArrowDown on the trigger opens the menu and focuses the first item", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.keydown(trigger, "ArrowDown"), Scene.expectHandled(), @@ -96,7 +97,7 @@ describe("menu scene", () => { test("a key the trigger does not handle falls through", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.keydown(trigger, "a"), Scene.expectIgnored(), @@ -107,7 +108,7 @@ describe("menu scene", () => { test("clicking an item closes the menu and reports the selection", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.pointerDown(trigger), portalled, @@ -122,7 +123,7 @@ describe("menu scene", () => { test("a disabled item is aria-disabled and clicking it keeps the menu open", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.pointerDown(trigger), portalled, @@ -136,7 +137,7 @@ describe("menu scene", () => { test("a single-selection menu renders radio items with the selected one checked", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries, selectionMode: "Single", selectedKeys: ["open"] })), + Scene.given(init({ id: "file", selectionMode: "Single", selectedKeys: ["open"] })), mounted, Scene.pointerDown(trigger), portalled, @@ -163,7 +164,7 @@ describe("menu scene", () => { test("ArrowRight on a submenu trigger expands it and Escape collapses it again", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.keydown(trigger, "ArrowUp"), portalled, @@ -190,7 +191,7 @@ describe("menu scene", () => { test("the hidden Dismiss button closes the menu", () => { Scene.scene( config, - Scene.given(init({ id: "file", entries })), + Scene.given(init({ id: "file" })), mounted, Scene.pointerDown(trigger), portalled, @@ -208,7 +209,7 @@ describe("context menu scene", () => { test("a right-click opens the menu at the pointer, unlabelled by the trigger", () => { Scene.scene( contextConfig, - Scene.given(init({ id: "file", entries, anchor: "Pointer" })), + Scene.given(init({ id: "file", anchor: "Pointer" })), Scene.expect(trigger).toHaveAttr("aria-haspopup", "menu"), Scene.Mount.resolve(capture, Message.PressedContextMenu({ offset: -20, crossOffset: 40 })), portalled, @@ -222,7 +223,7 @@ describe("context menu scene", () => { test("arrow keys go to the menu, the popover itself only handles Escape", () => { Scene.scene( contextConfig, - Scene.given(init({ id: "file", entries, anchor: "Pointer" })), + Scene.given(init({ id: "file", anchor: "Pointer" })), Scene.Mount.resolve(capture, Message.PressedContextMenu({ offset: 0, crossOffset: 0 })), portalled, Scene.keydown(Scene.role("dialog"), "ArrowDown"), diff --git a/apps/web-foldkit/src/ui/menu.story.test.ts b/apps/web-foldkit/src/ui/menu.story.test.ts index a2e00560b..c4a3b40da 100644 --- a/apps/web-foldkit/src/ui/menu.story.test.ts +++ b/apps/web-foldkit/src/ui/menu.story.test.ts @@ -25,8 +25,8 @@ const entries = [ }), item("docs", { textValue: "Docs", href: "/docs" }), ] -const menu = init({ id: "file", entries }) -const pressMenuKey = (key: string, isModified = false) => message(Message.PressedMenuKey({ key, isModified })) +const menu = init({ id: "file" }) +const pressMenuKey = (key: string, isModified = false) => message(Message.PressedMenuKey({ key, isModified, entries: entries })) const focusOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.focusedKey : Option.none()) const submenuOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.submenu : Option.none()) const searchOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.search : "") @@ -49,7 +49,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), Command.expectNone(), model((next) => { expect(next.popup._tag).toBe("Open") @@ -62,7 +62,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowUp" })), + message(Message.PressedTriggerKey({ key: "ArrowUp", entries: entries })), model((next) => expect(focusOf(next)).toEqual(Option.some("docs"))), ) }) @@ -84,8 +84,8 @@ describe("menu story", () => { test("a single-selection menu opens on its selected item", () => { story( update, - given(init({ id: "file", entries, selectionMode: "Single", selectedKeys: ["open"] })), - message(Message.PressedTriggerKey({ key: "Enter" })), + given(init({ id: "file", selectionMode: "Single", selectedKeys: ["open"] })), + message(Message.PressedTriggerKey({ key: "Enter", entries: entries })), model((next) => expect(focusOf(next)).toEqual(Option.some("open"))), ) }) @@ -94,7 +94,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), pressMenuKey("ArrowDown"), resolveFocus, pressMenuKey("ArrowDown"), @@ -112,8 +112,8 @@ describe("menu story", () => { test("Enter activates the focused item, closes the menu and selects it in single mode", () => { story( update, - given(init({ id: "file", entries, selectionMode: "Single" })), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + given(init({ id: "file", selectionMode: "Single" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), pressMenuKey("Enter"), expectOutMessage(OutMessage.SelectedItem({ key: "new" })), model((next) => { @@ -127,11 +127,11 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowUp" })), + message(Message.PressedTriggerKey({ key: "ArrowUp", entries: entries })), pressMenuKey(" "), expectOutMessage(OutMessage.ActivatedLink({ key: "docs", href: "/docs" })), message(Message.PressedTrigger({ pointerType: "mouse" })), - message(Message.ClickedItem({ key: "docs" })), + message(Message.ClickedItem({ key: "docs", entries: entries })), expectOutMessage(OutMessage.SelectedItem({ key: "docs" })), ) }) @@ -141,7 +141,7 @@ describe("menu story", () => { update, given(menu), message(Message.PressedTrigger({ pointerType: "mouse" })), - message(Message.ClickedItem({ key: "archive" })), + message(Message.ClickedItem({ key: "archive", entries: entries })), expectNoOutMessage(), model((next) => expect(next.popup._tag).toBe("Open")), ) @@ -151,7 +151,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), pressMenuKey("ArrowDown"), resolveFocus, pressMenuKey("ArrowDown"), @@ -177,13 +177,13 @@ describe("menu story", () => { update, given(menu), message(Message.PressedTrigger({ pointerType: "mouse" })), - message(Message.ClickedItem({ key: "share" })), + message(Message.ClickedItem({ key: "share", entries: entries })), expectNoOutMessage(), Command.expectNone(), model((next) => expect(Option.map(submenuOf(next), (s) => s.triggerKey)).toEqual(Option.some("share")), ), - message(Message.HoveredItem({ key: "email" })), + message(Message.HoveredItem({ key: "email", entries: entries })), resolveFocus, pressMenuKey("Escape"), resolveFocus, @@ -202,7 +202,7 @@ describe("menu story", () => { update, given(menu), message(Message.PressedTrigger({ pointerType: "mouse" })), - message(Message.HoveredItem({ key: "archive" })), + message(Message.HoveredItem({ key: "archive", entries: entries })), Command.expectNone(), model((next) => expect(focusOf(next)).toEqual(Option.none())), ) @@ -212,7 +212,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), pressMenuKey("s"), Command.expectExact( FocusMenuElement({ elementId: "file-item-share" }), @@ -244,7 +244,7 @@ describe("menu story", () => { story( update, given(menu), - message(Message.PressedTriggerKey({ key: "ArrowDown" })), + message(Message.PressedTriggerKey({ key: "ArrowDown", entries: entries })), pressMenuKey("o", true), Command.expectNone(), pressMenuKey("a"), @@ -274,7 +274,7 @@ describe("menu story", () => { test("a context menu opens at the pointer with nothing focused", () => { story( update, - given(init({ id: "file", entries, anchor: "Pointer" })), + given(init({ id: "file", anchor: "Pointer" })), message(Message.PressedContextMenu({ offset: -40, crossOffset: 12 })), Command.expectNone(), model((next) => { diff --git a/apps/web-foldkit/src/ui/menu.ts b/apps/web-foldkit/src/ui/menu.ts index 59a7bfdea..1fb291dbe 100644 --- a/apps/web-foldkit/src/ui/menu.ts +++ b/apps/web-foldkit/src/ui/menu.ts @@ -6,9 +6,9 @@ import { modifyFields } from "foldkit/struct" import { Placement } from "./aria/placement" /** - * Port of `components/ui/menu.tsx` (React Aria MenuTrigger, Menu, SubmenuTrigger). The Model holds - * the menu's structure and interaction state; item content comes from the view's `content` callback. - * The view lives in `menu-view.ts`. + * Menu, context menu and one submenu level. The parent owns the menu's structure: it passes `entries` + * to the view, which attaches them to the Messages that navigate, so the Model holds only the popup + * state and the selection. Item content comes from the view's `content` callback (`menu-view.ts`). */ // MODEL @@ -64,7 +64,6 @@ type Open = Extract<typeof Popup.Type, { _tag: "Open" }> export const Model = Schema.Struct({ id: Schema.String, - entries: Schema.Array(Entry), selectionMode: SelectionMode, selectedKeys: Schema.Array(Schema.String), placement: Placement, @@ -77,12 +76,12 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ PressedTrigger: { pointerType: Schema.String }, - PressedTriggerKey: { key: Schema.String }, + PressedTriggerKey: { key: Schema.String, entries: Schema.Array(Entry) }, PressedContextMenu: { offset: Schema.Number, crossOffset: Schema.Number }, - PressedMenuKey: { key: Schema.String, isModified: Schema.Boolean }, - HoveredItem: { key: Schema.String }, + PressedMenuKey: { key: Schema.String, isModified: Schema.Boolean, entries: Schema.Array(Entry) }, + HoveredItem: { key: Schema.String, entries: Schema.Array(Entry) }, UnhoveredItem: { key: Schema.String }, - ClickedItem: { key: Schema.String }, + ClickedItem: { key: Schema.String, entries: Schema.Array(Entry) }, PressedOutside: {}, ClickedDismiss: {}, CompletedFocusElement: {}, @@ -146,7 +145,6 @@ export const separator: Entry = { _tag: "Separator" } export const init = (config: { readonly id: string - readonly entries: ReadonlyArray<Entry> readonly selectionMode?: "None" | "Single" readonly selectedKeys?: ReadonlyArray<string> /** MenuContent `placement`; MenuTrigger's own default is `bottom start`. */ @@ -155,7 +153,6 @@ export const init = (config: { readonly anchor?: "Trigger" | "Pointer" }): Model => ({ id: config.id, - entries: config.entries, selectionMode: config.selectionMode ?? "None", selectedKeys: config.selectedKeys ?? [], placement: config.placement ?? "bottom start", @@ -163,10 +160,6 @@ export const init = (config: { popup: { _tag: "Closed" }, }) -/** The parent owns the menu's structure (it follows data), so it reflects it in; state is kept. */ -export const reflectEntries = (model: Model, entries: ReadonlyArray<Entry>): Model => - modifyFields(model, { entries: () => entries }) - // IDS export const triggerId = (id: string) => `${id}-trigger` @@ -180,8 +173,8 @@ export const headerId = (id: string, key: string) => `${id}-header-${key}` // COLLECTION -export const rootItems = (model: Model): ReadonlyArray<Item> => - Array.flatMap(model.entries, (entry) => +export const rootItems = (entries: ReadonlyArray<Entry>): ReadonlyArray<Item> => + Array.flatMap(entries, (entry) => Match.value(entry).pipe( Match.tagsExhaustive({ Item: ({ item }) => [item], @@ -191,11 +184,11 @@ export const rootItems = (model: Model): ReadonlyArray<Item> => ), ) -const findItem = (model: Model, key: string): Option.Option<Item> => - Array.findFirst(rootItems(model), (candidate) => candidate.key === key) +const findItem = (entries: ReadonlyArray<Entry>, key: string): Option.Option<Item> => + Array.findFirst(rootItems(entries), (candidate) => candidate.key === key) -const submenuItems = (model: Model, triggerKey: string): ReadonlyArray<Leaf> => - Option.match(findItem(model, triggerKey), { onNone: () => [], onSome: (found) => found.submenu }) +const submenuItems = (entries: ReadonlyArray<Entry>, triggerKey: string): ReadonlyArray<Leaf> => + Option.match(findItem(entries, triggerKey), { onNone: () => [], onSome: (found) => found.submenu }) const enabledKeys = (items: ReadonlyArray<Leaf>): ReadonlyArray<string> => Array.map( @@ -289,8 +282,13 @@ const withOpen = (model: Model, open: Open, isFocusMoved: boolean): UpdateReturn return isFocusMoved ? { model: nextModel, commands: [focusFor(nextModel, open)] } : { model: nextModel } } -const opened = (model: Model, strategy: "None" | "First" | "Last", modality: Modality): UpdateReturn => { - const items = rootItems(model) +const opened = ( + model: Model, + entries: ReadonlyArray<Entry>, + strategy: "None" | "First" | "Last", + modality: Modality, +): UpdateReturn => { + const items = rootItems(entries) const strategyKey = strategy === "None" ? Option.none<string>() @@ -314,12 +312,18 @@ const opened = (model: Model, strategy: "None" | "First" | "Last", modality: Mod const closed = (model: Model): Model => modifyFields(model, { popup: () => ({ _tag: "Closed" as const }) }) -const activated = (model: Model, open: Open, key: string, modality: Modality): UpdateReturn => { - const isSubmenuTrigger = Option.exists(findItem(model, key), (found) => +const activated = ( + model: Model, + entries: ReadonlyArray<Entry>, + open: Open, + key: string, + modality: Modality, +): UpdateReturn => { + const isSubmenuTrigger = Option.exists(findItem(entries, key), (found) => Array.isReadonlyArrayNonEmpty(found.submenu), ) if (isSubmenuTrigger) { - const first = stepKey(submenuItems(model, key), Option.none(), 1) + const first = stepKey(submenuItems(entries, key), Option.none(), 1) return withOpen( model, { @@ -335,7 +339,7 @@ const activated = (model: Model, open: Open, key: string, modality: Modality): U ) } const selectedKeys = model.selectionMode === "Single" ? [key] : model.selectedKeys - const href = Option.flatMap(findItem(model, key), (found) => found.href) + const href = Option.flatMap(findItem(entries, key), (found) => found.href) return { model: modifyFields(closed(model), { selectedKeys: () => selectedKeys }), outMessage: @@ -345,12 +349,18 @@ const activated = (model: Model, open: Open, key: string, modality: Modality): U } } -const pressedMenuKey = (model: Model, open: Open, key: string, isModified: boolean): UpdateReturn => { +const pressedMenuKey = ( + model: Model, + entries: ReadonlyArray<Entry>, + open: Open, + key: string, + isModified: boolean, +): UpdateReturn => { const keyboard: Open = { ...open, modality: "Keyboard" } const inSubmenu = Option.filter(open.submenu, (submenu) => Option.isSome(submenu.focusedKey)) const items: ReadonlyArray<Leaf> = Option.match(inSubmenu, { - onNone: () => rootItems(model), - onSome: (submenu) => submenuItems(model, submenu.triggerKey), + onNone: () => rootItems(entries), + onSome: (submenu) => submenuItems(entries, submenu.triggerKey), }) const current = Option.match(inSubmenu, { onNone: () => open.focusedKey, @@ -389,20 +399,20 @@ const pressedMenuKey = (model: Model, open: Open, key: string, isModified: boole () => Option.match(current, { onNone: () => ({ model }), - onSome: (focusedKey) => activated(model, keyboard, focusedKey, "Keyboard"), + onSome: (focusedKey) => activated(model, entries, keyboard, focusedKey, "Keyboard"), }), ), Match.when("ArrowRight", () => { const submenuTrigger = Option.filter( Option.filter(current, () => Option.isNone(inSubmenu)), (focusedKey) => - Option.exists(findItem(model, focusedKey), (found) => + Option.exists(findItem(entries, focusedKey), (found) => Array.isReadonlyArrayNonEmpty(found.submenu), ), ) return Option.match(submenuTrigger, { onNone: () => ({ model }), - onSome: (focusedKey) => activated(model, keyboard, focusedKey, "Keyboard"), + onSome: (focusedKey) => activated(model, entries, keyboard, focusedKey, "Keyboard"), }) }), Match.orElse(() => { @@ -428,12 +438,12 @@ export const update = (model: Model, message: Message): UpdateReturn => { return Message.match<UpdateReturn>(message, { PressedTrigger: ({ pointerType }) => model.popup._tag === "Closed" && pointerType !== "touch" - ? opened(model, "None", "Pointer") + ? opened(model, [], "None", "Pointer") : { model }, - PressedTriggerKey: ({ key }) => + PressedTriggerKey: ({ key, entries }) => Match.value(key).pipe( - Match.whenOr("Enter", " ", "ArrowDown", () => opened(model, "First", "Keyboard")), - Match.when("ArrowUp", () => opened(model, "Last", "Keyboard")), + Match.whenOr("Enter", " ", "ArrowDown", () => opened(model, entries, "First", "Keyboard")), + Match.when("ArrowUp", () => opened(model, entries, "Last", "Keyboard")), Match.orElse(() => ({ model })), ), PressedContextMenu: ({ offset, crossOffset }) => @@ -450,13 +460,13 @@ export const update = (model: Model, message: Message): UpdateReturn => { }, false, ), - PressedMenuKey: ({ key, isModified }) => - ifOpen((open) => pressedMenuKey(model, open, key, isModified)), - HoveredItem: ({ key }) => + PressedMenuKey: ({ key, isModified, entries }) => + ifOpen((open) => pressedMenuKey(model, entries, open, key, isModified)), + HoveredItem: ({ key, entries }) => ifOpen((open) => { const pointer: Open = { ...open, modality: "Pointer", hoveredKey: Option.some(key) } const inSubmenu = Option.exists(open.submenu, (submenu) => - Array.some(submenuItems(model, submenu.triggerKey), (candidate) => candidate.key === key), + Array.some(submenuItems(entries, submenu.triggerKey), (candidate) => candidate.key === key), ) if (inSubmenu) return withOpen( @@ -470,7 +480,7 @@ export const update = (model: Model, message: Message): UpdateReturn => { }, true, ) - const hovered = findItem(model, key) + const hovered = findItem(entries, key) if (Option.exists(hovered, (found) => found.isDisabled)) return withOpen(model, pointer, false) const hasSubmenu = Option.exists(hovered, (found) => @@ -489,18 +499,18 @@ export const update = (model: Model, message: Message): UpdateReturn => { false, ), ), - ClickedItem: ({ key }) => + ClickedItem: ({ key, entries }) => ifOpen((open) => { - const clicked = Option.orElse(findItem(model, key), () => + const clicked = Option.orElse(findItem(entries, key), () => Option.flatMap(open.submenu, (submenu) => Array.findFirst( - submenuItems(model, submenu.triggerKey), + submenuItems(entries, submenu.triggerKey), (candidate) => candidate.key === key, ), ), ) return Option.exists(clicked, (found) => !found.isDisabled) - ? activated(model, open, key, "Pointer") + ? activated(model, entries, open, key, "Pointer") : { model } }), PressedOutside: () => ({ model: closed(model) }), From 4d105689273be5b54d731a78e6ce1e09809b7a33 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:12:26 +0200 Subject: [PATCH 17/52] refactor(web): Select items come from the parent through view inputs The Select Model keeps only the selection and the popup; parents pass items in ViewInputs and the view attaches them to the navigating Messages. The set-status expiration labels and the calendar's month/year options are now derived in the view instead of copied into the child Model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/overlay/modal/email-invite.ts | 10 +- .../web-foldkit/src/overlay/modal/feedback.ts | 18 ++-- .../src/overlay/modal/new-channel.ts | 10 +- .../src/overlay/modal/set-status-model.ts | 7 +- .../src/overlay/modal/set-status-view.ts | 3 +- .../src/overlay/modal/set-status.ts | 14 +-- .../src/page/chat/files/page.test.ts | 4 +- apps/web-foldkit/src/page/chat/files/page.ts | 9 +- apps/web-foldkit/src/page/chat/files/view.ts | 4 +- .../src/page/my-settings/appearance/update.ts | 8 +- .../src/page/my-settings/appearance/view.ts | 3 +- apps/web-foldkit/src/ui/calendar-view.ts | 4 + .../web-foldkit/src/ui/calendar.story.test.ts | 3 +- apps/web-foldkit/src/ui/calendar.ts | 53 +++++------ apps/web-foldkit/src/ui/select-view.ts | 18 ++-- apps/web-foldkit/src/ui/select.scene.test.ts | 20 ++-- apps/web-foldkit/src/ui/select.story.test.ts | 22 ++--- apps/web-foldkit/src/ui/select.ts | 93 +++++++++++-------- 18 files changed, 154 insertions(+), 149 deletions(-) diff --git a/apps/web-foldkit/src/overlay/modal/email-invite.ts b/apps/web-foldkit/src/overlay/modal/email-invite.ts index 64c8dc7ce..0aa1f5a50 100644 --- a/apps/web-foldkit/src/overlay/modal/email-invite.ts +++ b/apps/web-foldkit/src/overlay/modal/email-invite.ts @@ -61,14 +61,12 @@ const SendInvites = Command.define("SendInvites", { const ID = "email-invite-modal" const MAX_INVITES = 10 +const ROLE_ITEMS = [Select.item("member", "Member"), Select.item("admin", "Admin")] + const newRow = (rowId: number): Row => ({ rowId, email: "", - role: Select.init({ - id: `${ID}-role-${rowId}`, - items: [Select.item("member", "Member"), Select.item("admin", "Admin")], - selectedKey: "member", - }), + role: Select.init({ id: `${ID}-role-${rowId}`, selectedKey: "member" }), }) const roleOf = (row: Row): InvitationRole => @@ -185,7 +183,7 @@ const inviteRow = (h: HtmlBuilder<Message>, row: Row, index: number, count: numb slotId: row.role.id, model: row.role, view: selectView, - viewInputs: {}, + viewInputs: { items: ROLE_ITEMS }, toParentMessage: (message: Select.Message) => Message.GotRoleMessage({ rowId: row.rowId, message }), }), ], diff --git a/apps/web-foldkit/src/overlay/modal/feedback.ts b/apps/web-foldkit/src/overlay/modal/feedback.ts index 9ed7d5dbe..3c0df5858 100644 --- a/apps/web-foldkit/src/overlay/modal/feedback.ts +++ b/apps/web-foldkit/src/overlay/modal/feedback.ts @@ -42,6 +42,12 @@ const validateMessage = (message: string) => const ID = "feedback-modal" +const CATEGORY_ITEMS = [ + Select.item("bug", "Bug"), + Select.item("feature_request", "Feature Request"), + Select.item("question", "Question"), +] + type Return = ModalReturn<Model, Message> const update = (model: Model, message: Message): Return => @@ -92,7 +98,7 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input slotId: `${ID}-category-select`, model: model.category, view: selectView, - viewInputs: {}, + viewInputs: { items: CATEGORY_ITEMS }, toParentMessage: toCategoryMessage, }), ]), @@ -137,15 +143,7 @@ export const modal = defineModal( init: () => ({ model: { frame: initFrame(ID), - category: Select.init({ - id: `${ID}-category-select`, - items: [ - Select.item("bug", "Bug"), - Select.item("feature_request", "Feature Request"), - Select.item("question", "Question"), - ], - selectedKey: "bug", - }), + category: Select.init({ id: `${ID}-category-select`, selectedKey: "bug" }), message: "", messageError: null, }, diff --git a/apps/web-foldkit/src/overlay/modal/new-channel.ts b/apps/web-foldkit/src/overlay/modal/new-channel.ts index bd4ca1c02..3e6f5b89c 100644 --- a/apps/web-foldkit/src/overlay/modal/new-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/new-channel.ts @@ -71,6 +71,8 @@ const validateName = (name: string) => const ID = "create-channel-modal" +const CHANNEL_TYPE_ITEMS = [Select.item("public", "Public"), Select.item("private", "Private")] + type Return = ModalReturn<Model, Message> const submitted = (model: Model, shared: Shared): Return => { @@ -168,7 +170,7 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input slotId: `${ID}-type`, model: model.channelType, view: selectView, - viewInputs: {}, + viewInputs: { items: CHANNEL_TYPE_ITEMS }, toParentMessage: toChannelTypeMessage, }), checkbox( @@ -211,11 +213,7 @@ export const modal = defineModal( frame: initFrame(ID), name: "", nameError: null, - channelType: Select.init({ - id: `${ID}-type`, - items: [Select.item("public", "Public"), Select.item("private", "Private")], - selectedKey: "public", - }), + channelType: Select.init({ id: `${ID}-type`, selectedKey: "public" }), addAllMembers: false, isSubmitting: false, }, diff --git a/apps/web-foldkit/src/overlay/modal/set-status-model.ts b/apps/web-foldkit/src/overlay/modal/set-status-model.ts index 52571d2ed..89de2f18c 100644 --- a/apps/web-foldkit/src/overlay/modal/set-status-model.ts +++ b/apps/web-foldkit/src/overlay/modal/set-status-model.ts @@ -126,13 +126,12 @@ const customLabel = (model: Model): string | null => { : date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) } -/** Rebuilds the Select items so the custom option shows the chosen date and time. */ -export const withExpirationItems = (model: Model): Model => { +/** The expiration Select's items: the custom option shows the chosen date and time. */ +export const expirationItems = (model: Model): ReadonlyArray<Select.Item> => { const label = customLabel(model) - const items = EXPIRATION_OPTIONS.map((option) => + return EXPIRATION_OPTIONS.map((option) => Select.item(option.id, option.id === "custom" && label !== null ? label : option.label), ) - return { ...model, expiration: { ...model.expiration, items } } } const pad = (value: number) => String(value).padStart(2, "0") diff --git a/apps/web-foldkit/src/overlay/modal/set-status-view.ts b/apps/web-foldkit/src/overlay/modal/set-status-view.ts index f3014f9d8..b26ed0f8e 100644 --- a/apps/web-foldkit/src/overlay/modal/set-status-view.ts +++ b/apps/web-foldkit/src/overlay/modal/set-status-view.ts @@ -21,6 +21,7 @@ import { Message, type Model, STATUS_PRESETS, + expirationItems, } from "./set-status-model" /** The set-status modal's view (legacy `SetStatusModal` markup). */ @@ -101,7 +102,7 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, slotId: `${ID}-expiration-select`, model: model.expiration, view: selectView, - viewInputs: {}, + viewInputs: { items: expirationItems(model) }, toParentMessage: toExpirationMessage, }) return frameView( diff --git a/apps/web-foldkit/src/overlay/modal/set-status.ts b/apps/web-foldkit/src/overlay/modal/set-status.ts index 95bbc01c3..8400db9a6 100644 --- a/apps/web-foldkit/src/overlay/modal/set-status.ts +++ b/apps/web-foldkit/src/overlay/modal/set-status.ts @@ -18,7 +18,6 @@ import { defineModal, type ModalReturn, type ModalSubscriptionInput } from "./co import { initFrame, isFrameClosed } from "./frame" import { customDateOf, - EXPIRATION_OPTIONS, expirationDate, expirationOf, ID, @@ -28,7 +27,6 @@ import { nextHourValue, type Presence, todayValue, - withExpirationItems, } from "./set-status-model" import { view } from "./set-status-view" import { failureToast, successToast } from "../../data/actions" @@ -85,7 +83,7 @@ const expirationChanged = (model: Model, result: ReturnType<typeof Select.update }) : next return { - model: withExpirationItems(withDefaults), + model: withDefaults, commands: Command.mapMessages(result.commands, (message) => Message.GotExpirationMessage({ message })), } } @@ -94,7 +92,7 @@ const customDateChanged = (model: Model, message: DatePicker.Message): Return => if (model.customDate === null) return { model } const result = DatePicker.update(model.customDate, message) return { - model: withExpirationItems({ ...model, customDate: result.model }), + model: { ...model, customDate: result.model }, commands: Command.mapMessages(result.commands ?? [], (child) => Message.GotCustomDateMessage({ message: child }), ), @@ -125,7 +123,7 @@ const segmentsChanged = ( if (current === null) return { model } const result = Segments.update(current, message) return { - model: withExpirationItems({ ...model, [field]: result.model }), + model: { ...model, [field]: result.model }, commands: Command.mapMessages(result.commands, toMessage), } } @@ -264,11 +262,7 @@ export const modal = defineModal( emojiPicker: EmojiDialog.init(`${ID}-emoji-picker`), customEmojis: [], message: "", - expiration: Select.init({ - id: `${ID}-expiration-select`, - items: EXPIRATION_OPTIONS.map((option) => Select.item(option.id, option.label)), - selectedKey: "never", - }), + expiration: Select.init({ id: `${ID}-expiration-select`, selectedKey: "never" }), customDate: null, customTime: null, pauseNotifications: false, diff --git a/apps/web-foldkit/src/page/chat/files/page.test.ts b/apps/web-foldkit/src/page/chat/files/page.test.ts index 7ca5a77e6..579c70d6e 100644 --- a/apps/web-foldkit/src/page/chat/files/page.test.ts +++ b/apps/web-foldkit/src/page/chat/files/page.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { describe, expect, test, vi } from "vitest" import * as Select from "../../../ui/select" import { documentsOf, filterAttachments, formatRelativeTime, mediaOf, visibleCountFor } from "./derive" -import { init, Message, setView, update } from "./page" +import { filterItems, init, Message, setView, update } from "./page" import type { FileAttachment } from "./queries" /** Files tab update loop and derivations: search, type filter, relative dates, route resets. */ @@ -72,7 +72,7 @@ describe("update", () => { ).model const picked = update( opened, - Message.GotFilterSelectMessage({ message: Select.Message.ClickedOption({ key: "image" }) }), + Message.GotFilterSelectMessage({ message: Select.Message.ClickedOption({ key: "image", items: filterItems }) }), ).model expect(picked.filterType).toBe("image") expect(picked.filter.popup._tag).toBe("Closed") diff --git a/apps/web-foldkit/src/page/chat/files/page.ts b/apps/web-foldkit/src/page/chat/files/page.ts index 833f50544..db2e8b7ba 100644 --- a/apps/web-foldkit/src/page/chat/files/page.ts +++ b/apps/web-foldkit/src/page/chat/files/page.ts @@ -61,6 +61,9 @@ export type Message = typeof Message.Type export const SEARCH_FIELD_ID = "channel-files-search" export const FILTER_SELECT_ID = "channel-files-filter" +/** The filter Select's options. */ +export const filterItems = filterOptions.map((option) => Select.item(option.id, option.label)) + export const init = (channelId: ChannelId, orgSlug: string, view: FilesView): Model => ({ channelId, orgSlug, @@ -69,11 +72,7 @@ export const init = (channelId: ChannelId, orgSlug: string, view: FilesView): Mo nowMs: 0, searchQuery: "", filterType: "all", - filter: Select.init({ - id: FILTER_SELECT_ID, - items: filterOptions.map((option) => Select.item(option.id, option.label)), - selectedKey: "all", - }), + filter: Select.init({ id: FILTER_SELECT_ID, selectedKey: "all" }), breakpoint: "base", failedMediaIds: [], viewerImageId: null, diff --git a/apps/web-foldkit/src/page/chat/files/view.ts b/apps/web-foldkit/src/page/chat/files/view.ts index 6f7d89968..217b30d04 100644 --- a/apps/web-foldkit/src/page/chat/files/view.ts +++ b/apps/web-foldkit/src/page/chat/files/view.ts @@ -8,7 +8,7 @@ import { searchField } from "../../../ui/search-field" import { view as selectView } from "../../../ui/select-view" import { documentsOf, filterAttachments, formatRelativeTime, mediaOf, uploaderIdentity } from "./derive" import { type FilesContext, filesHref, mediaGalleryView, mediaGridView } from "./media-view" -import { Message, type Model, SEARCH_FIELD_ID } from "./page" +import { filterItems, Message, type Model, SEARCH_FIELD_ID } from "./page" import type { FileAttachment } from "./queries" /** The `files/` and `files/media` route outlets (the lead renders the chat header and tab bar). */ @@ -37,7 +37,7 @@ const headerView = <M>(context: FilesContext<M>, fileCount: number): Html => { model: model.filter, view: selectView, // Kit gap: legacy has no Label and puts `w-36` on the trigger (see the report). - viewInputs: { triggerClassName: "w-36" }, + viewInputs: { items: filterItems, triggerClassName: "w-36" }, toParentMessage: (message) => toParentMessage(Message.GotFilterSelectMessage({ message })), }), h.span( diff --git a/apps/web-foldkit/src/page/my-settings/appearance/update.ts b/apps/web-foldkit/src/page/my-settings/appearance/update.ts index 959eb0e8d..53af45b84 100644 --- a/apps/web-foldkit/src/page/my-settings/appearance/update.ts +++ b/apps/web-foldkit/src/page/my-settings/appearance/update.ts @@ -26,12 +26,10 @@ export const toGrayPaletteMessage = (message: Select.Message) => Message.GotGray const isGrayPalette = (key: string): key is Theme.GrayPalette => GRAY_PALETTES.some((palette) => palette === key) +export const GRAY_PALETTE_ITEMS = GRAY_PALETTES.map((palette) => Select.item(palette, GRAY_PALETTE_LABELS[palette])) + const grayPaletteSelect = (selected: Theme.GrayPalette) => - Select.init({ - id: "gray-palette", - items: GRAY_PALETTES.map((palette) => Select.item(palette, GRAY_PALETTE_LABELS[palette])), - selectedKey: selected, - }) + Select.init({ id: "gray-palette", selectedKey: selected }) /** The gray palette Select follows `Shared.theme`, whoever changed it. */ const withGrayPalette = (model: Model, shared: Shared): Model => diff --git a/apps/web-foldkit/src/page/my-settings/appearance/view.ts b/apps/web-foldkit/src/page/my-settings/appearance/view.ts index c30ad12a6..42c2aa868 100644 --- a/apps/web-foldkit/src/page/my-settings/appearance/view.ts +++ b/apps/web-foldkit/src/page/my-settings/appearance/view.ts @@ -13,7 +13,7 @@ import { Dark, Light, System } from "./illustrations" import { Message } from "./message" import type { Model, ThemeMode } from "./model" import { RADIUS_PRESETS, SWATCH_COLOR_NAMES, toGrayPalette } from "./presets" -import { GENERATE_TARGET, interaction, toGrayPaletteMessage } from "./update" +import { GENERATE_TARGET, GRAY_PALETTE_ITEMS, interaction, toGrayPaletteMessage } from "./update" /** Port of `routes/_app/$orgSlug/my-settings/index.tsx` (Appearance). */ @@ -166,6 +166,7 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, model: model.grayPalette, view: selectView, viewInputs: { + items: GRAY_PALETTE_ITEMS, label: "", ariaLabel: "Gray palette", className: "w-full", diff --git a/apps/web-foldkit/src/ui/calendar-view.ts b/apps/web-foldkit/src/ui/calendar-view.ts index 4ae85c7f2..a8d892e39 100644 --- a/apps/web-foldkit/src/ui/calendar-view.ts +++ b/apps/web-foldkit/src/ui/calendar-view.ts @@ -19,6 +19,8 @@ import { isPreviousDisabled, Message, type Model, + monthItems, + yearItems, } from "./calendar" import * as D from "./calendar-date" import { view as selectView } from "./select-view" @@ -86,6 +88,7 @@ const header = (h: HtmlBuilder<Message>, model: Model, monthYear: string): Html model: model.month, view: selectView, viewInputs: { + items: monthItems(model.focusedDate), ariaLabel: "Month", className: "[popover-width:8rem]", style: "flex: 1 1 0%; width: fit-content;", @@ -100,6 +103,7 @@ const header = (h: HtmlBuilder<Message>, model: Model, monthYear: string): Html model: model.year, view: selectView, viewInputs: { + items: yearItems(model.focusedDate), ariaLabel: "Year", triggerClassName: calendarYearSelectTriggerClassName, isHiddenOptionTextless: true, diff --git a/apps/web-foldkit/src/ui/calendar.story.test.ts b/apps/web-foldkit/src/ui/calendar.story.test.ts index ed4fb329b..47a755591 100644 --- a/apps/web-foldkit/src/ui/calendar.story.test.ts +++ b/apps/web-foldkit/src/ui/calendar.story.test.ts @@ -8,6 +8,7 @@ import { Message, OutMessage, update, + yearItems, } from "./calendar" import * as Select from "./select" @@ -225,7 +226,7 @@ describe("calendar story: single selection", () => { update, given(event), message( - Message.GotYearMessage({ message: Select.Message.PressedTriggerKey({ key: "ArrowRight" }) }), + Message.GotYearMessage({ message: Select.Message.PressedTriggerKey({ key: "ArrowRight", items: yearItems(event.focusedDate) }) }), ), Command.expectExact( AnnounceCalendar({ message: "March 2027", timeout: 7000, assertiveness: "assertive" }), diff --git a/apps/web-foldkit/src/ui/calendar.ts b/apps/web-foldkit/src/ui/calendar.ts index bde1e8927..dbf1b0cd8 100644 --- a/apps/web-foldkit/src/ui/calendar.ts +++ b/apps/web-foldkit/src/ui/calendar.ts @@ -30,26 +30,27 @@ export type Model = typeof Model.Type const YEAR_SPAN = 20 -const monthSelect = (id: string, focusedDate: D.CalendarDate) => - Select.init({ - id: `${id}-month`, - items: Array.makeBy(12, (index) => - Select.item( - String(index + 1), - D.formatShortMonth(D.withMonth(focusedDate, D.parts(focusedDate).year, index + 1)), - ), +/** The month Select's items around the focused date (keys are month numbers). */ +export const monthItems = (focusedDate: D.CalendarDate): ReadonlyArray<Select.Item> => + Array.makeBy(12, (index) => + Select.item( + String(index + 1), + D.formatShortMonth(D.withMonth(focusedDate, D.parts(focusedDate).year, index + 1)), ), - selectedKey: String(D.parts(focusedDate).month), - }) + ) -const yearSelect = (id: string, focusedDate: D.CalendarDate) => - Select.init({ - id: `${id}-year`, - items: Array.makeBy(YEAR_SPAN * 2 + 1, (index) => - Select.item(String(index), D.formatYear(D.addYears(focusedDate, index - YEAR_SPAN))), - ), - selectedKey: String(YEAR_SPAN), - }) +/** The year Select's items, `YEAR_SPAN` years either side of the focused date (keys are offsets). */ +export const yearItems = (focusedDate: D.CalendarDate): ReadonlyArray<Select.Item> => + Array.makeBy(YEAR_SPAN * 2 + 1, (index) => + Select.item(String(index), D.formatYear(D.addYears(focusedDate, index - YEAR_SPAN))), + ) + +const monthKey = (focusedDate: D.CalendarDate) => String(D.parts(focusedDate).month) + +const monthSelect = (id: string, focusedDate: D.CalendarDate) => + Select.init({ id: `${id}-month`, selectedKey: monthKey(focusedDate) }) + +const yearSelect = (id: string) => Select.init({ id: `${id}-year`, selectedKey: String(YEAR_SPAN) }) /** useCalendarState's constrainValue: the focused date never precedes `minValue`. */ const clampToMin = (minValue: Option.Option<D.CalendarDate>, date: D.CalendarDate): D.CalendarDate => @@ -73,7 +74,7 @@ export const init = (config: { value: Option.fromNullishOr(config.value), minValue, month: monthSelect(config.id, focusedDate), - year: yearSelect(config.id, focusedDate), + year: yearSelect(config.id), interaction: Interaction.init(), } } @@ -167,21 +168,13 @@ export const FocusCalendarCell = Command.define("FocusCalendarCell", { type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> -/** Moves the focused date (clamped to `minValue`), rebuilding the header Selects around it. */ +/** Moves the focused date (clamped to `minValue`); the year Select stays centred on it. */ const withFocusedDate = (model: Model, date: D.CalendarDate): Model => { const focusedDate = clampToMin(model.minValue, date) return modifyFields(model, { focusedDate: () => focusedDate, - month: (month) => - modifyFields(monthSelect(model.id, focusedDate), { - popup: () => month.popup, - isTriggerFocused: () => month.isTriggerFocused, - }), - year: (year) => - modifyFields(yearSelect(model.id, focusedDate), { - popup: () => year.popup, - isTriggerFocused: () => year.isTriggerFocused, - }), + month: (month) => modifyFields(month, { selectedKey: () => Option.some(monthKey(focusedDate)) }), + year: (year) => modifyFields(year, { selectedKey: () => Option.some(String(YEAR_SPAN)) }), }) } diff --git a/apps/web-foldkit/src/ui/select-view.ts b/apps/web-foldkit/src/ui/select-view.ts index 133423ada..e6c5f2705 100644 --- a/apps/web-foldkit/src/ui/select-view.ts +++ b/apps/web-foldkit/src/ui/select-view.ts @@ -104,6 +104,8 @@ const FocusTriggerOnPress = Mount.define("FocusSelectTriggerOnPress", { // VIEW export type ViewInputs = Readonly<{ + /** The options, owned by the parent. */ + items: ReadonlyArray<Item> /** The `Label` inside the Select; omit it and pass `ariaLabel` for an unlabelled Select. */ label?: string ariaLabel?: string @@ -134,7 +136,7 @@ const optionLabel = <M>(h: HtmlBuilder<M>, label: string, id?: string): Html => export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { const isOpen = model.popup._tag === "Open" const selectedItem = Option.flatMap(model.selectedKey, (key) => - Array.findFirst(model.items, (candidate) => candidate.key === key), + Array.findFirst(viewInputs.items, (candidate) => candidate.key === key), ) const flag = (name: string, isOn: boolean) => (isOn ? [h.Attribute(name, "true")] : []) @@ -203,7 +205,7 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view ["Enter", " ", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"], key, ) - ? Option.some(Message.PressedTriggerKey({ key })) + ? Option.some(Message.PressedTriggerKey({ key, items: viewInputs.items })) : Option.none(), ), ], @@ -275,7 +277,7 @@ const hiddenSelect = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Messa [ h.option([]), // Keyed by label so a relabelled option is replaced and Chrome re-measures the select, as React does on every commit. - ...Array.map(model.items, (candidate) => + ...Array.map(viewInputs.items, (candidate) => h.option( [ h.Key(`${candidate.key}:${candidate.label}`), @@ -344,6 +346,7 @@ const selectPopover = (model: Model, open: Open, viewInputs: ViewInputs, h: Html modifiers.ctrlKey || modifiers.metaKey || modifiers.altKey, + items: viewInputs.items, }), ), ), @@ -383,7 +386,7 @@ const listbox = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde h.Role("listbox"), h.Attribute("tabindex", Option.isSome(open.focusedKey) ? "-1" : "0"), ], - Array.map(model.items, (candidate) => option(model, open, candidate, h, viewInputs.renderOption)), + Array.map(viewInputs.items, (candidate) => option(model, open, candidate, h, viewInputs)), ) const option = ( @@ -391,8 +394,9 @@ const option = ( open: Open, candidate: Item, h: HtmlBuilder<Message>, - renderOption: ViewInputs["renderOption"], + viewInputs: ViewInputs, ): Html => { + const { renderOption, items } = viewInputs const { key } = candidate const isSelected = Option.contains(model.selectedKey, key) const isFocused = Option.contains(open.focusedKey, key) @@ -418,9 +422,9 @@ const option = ( h.Id(optionId(model.id, key)), h.Role("option"), ...(candidate.isDisabled ? [] : [h.Attribute("tabindex", isFocused ? "0" : "-1")]), - h.OnMouseEnter(Message.HoveredOption({ key })), + h.OnMouseEnter(Message.HoveredOption({ key, items })), h.OnMouseLeave(Message.UnhoveredOption({ key })), - h.OnClick(Message.ClickedOption({ key })), + h.OnClick(Message.ClickedOption({ key, items })), ], [ ...(isSelected diff --git a/apps/web-foldkit/src/ui/select.scene.test.ts b/apps/web-foldkit/src/ui/select.scene.test.ts index 5c216228b..40e9a8b7b 100644 --- a/apps/web-foldkit/src/ui/select.scene.test.ts +++ b/apps/web-foldkit/src/ui/select.scene.test.ts @@ -12,7 +12,7 @@ const items = [ item("1h", "1 hour", true), item("today", "Today"), ] -const sceneView = Scene.withViewInputs(view, { label: "Clear after", placeholder: "Choose a time" }) +const sceneView = Scene.withViewInputs(view, { items, label: "Clear after", placeholder: "Choose a time" }) const config = { update, view: sceneView() } const trigger = Scene.selector("#clear-trigger") const listbox = Scene.role("listbox") @@ -26,7 +26,7 @@ describe("select scene", () => { test("a closed select shows its label, placeholder and a collapsed listbox trigger", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.expect(Scene.text("Clear after")).toExist(), Scene.expect(trigger).toContainText("Choose a time"), @@ -40,7 +40,7 @@ describe("select scene", () => { test("pressing the trigger opens the listbox with the selection marked", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items, selectedKey: "30m" })), + Scene.given(init({ id: "clear", selectedKey: "30m" })), mounted, Scene.pointerDown(trigger), portalled, @@ -57,7 +57,7 @@ describe("select scene", () => { test("ArrowDown on the trigger opens on the first option and arrows move the focus", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.keydown(trigger, "ArrowDown"), Scene.expectHandled(), @@ -74,7 +74,7 @@ describe("select scene", () => { test("clicking an option selects it, closes the listbox and shows its label", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.pointerDown(trigger), portalled, @@ -90,7 +90,7 @@ describe("select scene", () => { test("Escape closes the listbox and Tab is left to the browser", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.keydown(trigger, "Enter"), portalled, @@ -106,7 +106,7 @@ describe("select scene", () => { test("ArrowRight on the closed trigger changes the shown value without opening", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items, selectedKey: "never" })), + Scene.given(init({ id: "clear", selectedKey: "never" })), mounted, Scene.keydown(trigger, "ArrowRight"), Scene.expectOutMessage(OutMessage.ChangedSelection({ key: "30m" })), @@ -118,7 +118,7 @@ describe("select scene", () => { test("focusing the trigger marks the field focused until it blurs", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.focus(trigger), Scene.expect(Scene.selector("[data-focused]")).toHaveAttr("data-slot", "control"), @@ -130,7 +130,7 @@ describe("select scene", () => { test("a disabled select disables its trigger and hidden native select", () => { Scene.scene( config, - Scene.given(init({ id: "clear", items, isDisabled: true })), + Scene.given(init({ id: "clear", isDisabled: true })), mounted, Scene.expect(trigger).toBeDisabled(), Scene.expect(trigger).toHaveAttr("data-disabled", "true"), @@ -142,7 +142,7 @@ describe("select scene", () => { test("an aria-label-only select does not label its listbox by a missing element", () => { Scene.scene( { update, view: sceneView({ label: undefined, ariaLabel: "Clear after" }) }, - Scene.given(init({ id: "clear", items })), + Scene.given(init({ id: "clear" })), mounted, Scene.expect(trigger).toHaveAttr("aria-label", "Clear after"), Scene.pointerDown(trigger), diff --git a/apps/web-foldkit/src/ui/select.story.test.ts b/apps/web-foldkit/src/ui/select.story.test.ts index eb06f77ba..e830694d3 100644 --- a/apps/web-foldkit/src/ui/select.story.test.ts +++ b/apps/web-foldkit/src/ui/select.story.test.ts @@ -21,9 +21,9 @@ const items = [ item("today", "Today"), item("week", "This week"), ] -const select = init({ id: "clear", items }) -const triggerKey = (key: string) => message(Message.PressedTriggerKey({ key })) -const listKey = (key: string, isModified = false) => message(Message.PressedListKey({ key, isModified })) +const select = init({ id: "clear" }) +const triggerKey = (key: string) => message(Message.PressedTriggerKey({ key, items: items })) +const listKey = (key: string, isModified = false) => message(Message.PressedListKey({ key, isModified, items: items })) const focusOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.focusedKey : Option.none()) const searchOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.search : "") const resolveFocus = Command.resolve(FocusSelectElement, Message.CompletedFocusElement()) @@ -55,7 +55,7 @@ describe("select story", () => { test("opening focuses the selected option whatever the key", () => { story( update, - given(init({ id: "clear", items, selectedKey: "today" })), + given(init({ id: "clear", selectedKey: "today" })), triggerKey("ArrowUp"), model((next) => expect(focusOf(next)).toEqual(Option.some("today"))), ) @@ -64,7 +64,7 @@ describe("select story", () => { test("a disabled select does not open from a key or a press", () => { story( update, - given(init({ id: "clear", items, isDisabled: true })), + given(init({ id: "clear", isDisabled: true })), triggerKey("Enter"), message(Message.PressedTrigger({ pointerType: "mouse" })), model((next) => expect(next.popup._tag).toBe("Closed")), @@ -88,7 +88,7 @@ describe("select story", () => { test("ArrowRight on the closed trigger selects the next option without opening", () => { story( update, - given(init({ id: "clear", items, selectedKey: "30m" })), + given(init({ id: "clear", selectedKey: "30m" })), triggerKey("ArrowRight"), expectOutMessage(OutMessage.ChangedSelection({ key: "today" })), model((next) => { @@ -104,7 +104,7 @@ describe("select story", () => { test("ArrowRight on the last option keeps the selection and reports nothing", () => { story( update, - given(init({ id: "clear", items, selectedKey: "week" })), + given(init({ id: "clear", selectedKey: "week" })), triggerKey("ArrowRight"), expectNoOutMessage(), ) @@ -210,14 +210,14 @@ describe("select story", () => { story( update, given(openOn("never")), - message(Message.HoveredOption({ key: "1h" })), + message(Message.HoveredOption({ key: "1h", items: items })), Command.expectNone(), model((next) => expect(focusOf(next)).toEqual(Option.some("never"))), - message(Message.ClickedOption({ key: "1h" })), + message(Message.ClickedOption({ key: "1h", items: items })), expectNoOutMessage(), - message(Message.HoveredOption({ key: "today" })), + message(Message.HoveredOption({ key: "today", items: items })), resolveFocus, - message(Message.ClickedOption({ key: "today" })), + message(Message.ClickedOption({ key: "today", items: items })), expectOutMessage(OutMessage.ChangedSelection({ key: "today" })), ) }) diff --git a/apps/web-foldkit/src/ui/select.ts b/apps/web-foldkit/src/ui/select.ts index 2affb978c..ab63f6ad7 100644 --- a/apps/web-foldkit/src/ui/select.ts +++ b/apps/web-foldkit/src/ui/select.ts @@ -5,8 +5,9 @@ import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" /** - * Port of `components/ui/select.tsx` (React Aria Select + ListBox in a Popover) with string items, - * as the legacy SelectItem renders them. The view lives in `select-view.ts`. + * A select (trigger plus a listbox in a popover) over string items. The parent owns the items: it + * passes them to the view, which attaches them to the Messages that navigate, so the Model holds only + * the selection and the popup. The view lives in `select-view.ts`. */ // MODEL @@ -30,7 +31,6 @@ type Open = Extract<typeof Popup.Type, { _tag: "Open" }> export const Model = Schema.Struct({ id: Schema.String, - items: Schema.Array(Item), selectedKey: Schema.Option(Schema.String), isDisabled: Schema.Boolean, isTriggerFocused: Schema.Boolean, @@ -42,12 +42,10 @@ export const item = (key: string, label: string, isDisabled = false): Item => ({ export const init = (config: { readonly id: string - readonly items: ReadonlyArray<Item> readonly selectedKey?: string readonly isDisabled?: boolean }): Model => ({ id: config.id, - items: config.items, selectedKey: Option.fromNullishOr(config.selectedKey), isDisabled: config.isDisabled ?? false, isTriggerFocused: false, @@ -58,13 +56,13 @@ export const init = (config: { export const Message = defineMessageUnion({ PressedTrigger: { pointerType: Schema.String }, - PressedTriggerKey: { key: Schema.String }, + PressedTriggerKey: { key: Schema.String, items: Schema.Array(Item) }, FocusedTrigger: {}, BlurredTrigger: {}, - PressedListKey: { key: Schema.String, isModified: Schema.Boolean }, - HoveredOption: { key: Schema.String }, + PressedListKey: { key: Schema.String, isModified: Schema.Boolean, items: Schema.Array(Item) }, + HoveredOption: { key: Schema.String, items: Schema.Array(Item) }, UnhoveredOption: { key: Schema.String }, - ClickedOption: { key: Schema.String }, + ClickedOption: { key: Schema.String, items: Schema.Array(Item) }, PressedOutside: {}, ClickedDismiss: {}, CompletedFocusElement: {}, @@ -89,15 +87,19 @@ export const optionLabelId = (id: string, key: string) => `${id}-option-label-${ // COLLECTION -const enabledKeys = (model: Model): ReadonlyArray<string> => +const enabledKeys = (items: ReadonlyArray<Item>): ReadonlyArray<string> => Array.map( - Array.filter(model.items, (candidate) => !candidate.isDisabled), + Array.filter(items, (candidate) => !candidate.isDisabled), (candidate) => candidate.key, ) /** ListKeyboardDelegate without wrapping (ListBox's `shouldFocusWrap` is false). */ -const stepKey = (model: Model, from: Option.Option<string>, direction: 1 | -1): Option.Option<string> => { - const keys = enabledKeys(model) +const stepKey = ( + items: ReadonlyArray<Item>, + from: Option.Option<string>, + direction: 1 | -1, +): Option.Option<string> => { + const keys = enabledKeys(items) return Option.match( Option.flatMap(from, (key) => Array.findFirstIndex(keys, (candidate) => candidate === key)), { @@ -108,19 +110,23 @@ const stepKey = (model: Model, from: Option.Option<string>, direction: 1 | -1): ) } -const searchKey = (model: Model, search: string, from: Option.Option<string>): Option.Option<string> => { +const searchKey = ( + items: ReadonlyArray<Item>, + search: string, + from: Option.Option<string>, +): Option.Option<string> => { const matches = (candidate: Item) => !candidate.isDisabled && candidate.label.slice(0, search.length).toLowerCase() === search.toLowerCase() const start = Option.getOrElse( Option.flatMap(from, (key) => - Array.findFirstIndex(model.items, (candidate) => candidate.key === key), + Array.findFirstIndex(items, (candidate) => candidate.key === key), ), () => 0, ) return Option.map( - Option.orElse(Array.findFirst(Array.drop(model.items, start), matches), () => - Array.findFirst(model.items, matches), + Option.orElse(Array.findFirst(Array.drop(items, start), matches), () => + Array.findFirst(items, matches), ), (found) => found.key, ) @@ -161,12 +167,17 @@ const withOpen = (model: Model, open: Open, isFocusMoved: boolean): UpdateReturn } /** The portal Mount moves the initial focus, since the trigger's tree is inert until it runs. */ -const opened = (model: Model, strategy: "None" | "First" | "Last", modality: Modality): UpdateReturn => { +const opened = ( + model: Model, + items: ReadonlyArray<Item>, + strategy: "None" | "First" | "Last", + modality: Modality, +): UpdateReturn => { if (model.isDisabled) return { model } const strategyKey = strategy === "None" ? Option.none<string>() - : stepKey(model, Option.none(), strategy === "First" ? 1 : -1) + : stepKey(items, Option.none(), strategy === "First" ? 1 : -1) const focusedKey = Option.orElse(model.selectedKey, () => strategyKey) return withOpen( model, @@ -182,20 +193,20 @@ const selected = (model: Model, key: string): UpdateReturn => ({ outMessage: OutMessage.ChangedSelection({ key }), }) -const pressedTriggerKey = (model: Model, key: string): UpdateReturn => +const pressedTriggerKey = (model: Model, items: ReadonlyArray<Item>, key: string): UpdateReturn => Match.value(key).pipe( Match.when( (pressed) => pressed === "Enter" || pressed === " " || pressed === "ArrowDown", - () => opened(model, "First", "Keyboard"), + () => opened(model, items, "First", "Keyboard"), ), - Match.when("ArrowUp", () => opened(model, "Last", "Keyboard")), + Match.when("ArrowUp", () => opened(model, items, "Last", "Keyboard")), Match.when( (pressed) => pressed === "ArrowLeft" || pressed === "ArrowRight", (pressed) => // stepKey stays on the last key at an end; that is no change, so nothing is reported. Option.match( Option.filter( - stepKey(model, model.selectedKey, pressed === "ArrowRight" ? 1 : -1), + stepKey(items, model.selectedKey, pressed === "ArrowRight" ? 1 : -1), (next) => !Option.contains(model.selectedKey, next), ), { @@ -210,14 +221,20 @@ const pressedTriggerKey = (model: Model, key: string): UpdateReturn => Match.orElse(() => ({ model })), ) -const pressedListKey = (model: Model, open: Open, key: string, isModified: boolean): UpdateReturn => { +const pressedListKey = ( + model: Model, + items: ReadonlyArray<Item>, + open: Open, + key: string, + isModified: boolean, +): UpdateReturn => { const keyboard: Open = { ...open, modality: "Keyboard" } const focused = (next: Option.Option<string>) => withOpen(model, { ...keyboard, focusedKey: next }, true) return Match.value(key).pipe( - Match.when("ArrowDown", () => focused(stepKey(model, open.focusedKey, 1))), - Match.when("ArrowUp", () => focused(stepKey(model, open.focusedKey, -1))), - Match.when("Home", () => focused(stepKey(model, Option.none(), 1))), - Match.when("End", () => focused(stepKey(model, Option.none(), -1))), + Match.when("ArrowDown", () => focused(stepKey(items, open.focusedKey, 1))), + Match.when("ArrowUp", () => focused(stepKey(items, open.focusedKey, -1))), + Match.when("Home", () => focused(stepKey(items, Option.none(), 1))), + Match.when("End", () => focused(stepKey(items, Option.none(), -1))), Match.when("Escape", () => ({ model: closed(model) })), Match.when( (pressed) => pressed === "Enter" || (pressed === " " && open.search === ""), @@ -236,7 +253,7 @@ const pressedListKey = (model: Model, open: Open, key: string, isModified: boole ...keyboard, search, focusedKey: Option.orElse( - searchKey(model, search, open.focusedKey), + searchKey(items, search, open.focusedKey), () => open.focusedKey, ), }, @@ -257,18 +274,18 @@ export const update = (model: Model, message: Message): UpdateReturn => { return Message.match<UpdateReturn>(message, { PressedTrigger: ({ pointerType }) => model.popup._tag === "Closed" && pointerType !== "touch" - ? opened(model, "None", "Pointer") + ? opened(model, [], "None", "Pointer") : { model }, - PressedTriggerKey: ({ key }) => - model.popup._tag === "Closed" ? pressedTriggerKey(model, key) : { model }, + PressedTriggerKey: ({ key, items }) => + model.popup._tag === "Closed" ? pressedTriggerKey(model, items, key) : { model }, FocusedTrigger: () => ({ model: modifyFields(model, { isTriggerFocused: () => true }) }), BlurredTrigger: () => ({ model: modifyFields(model, { isTriggerFocused: () => false }) }), - PressedListKey: ({ key, isModified }) => - ifOpen((open) => pressedListKey(model, open, key, isModified)), - HoveredOption: ({ key }) => + PressedListKey: ({ key, isModified, items }) => + ifOpen((open) => pressedListKey(model, items, open, key, isModified)), + HoveredOption: ({ key, items }) => ifOpen((open) => { const isEnabled = Array.some( - model.items, + items, (candidate) => candidate.key === key && !candidate.isDisabled, ) const pointer: Open = { ...open, modality: "Pointer", hoveredKey: Option.some(key) } @@ -284,9 +301,9 @@ export const update = (model: Model, message: Message): UpdateReturn => { false, ), ), - ClickedOption: ({ key }) => + ClickedOption: ({ key, items }) => ifOpen(() => - Array.some(model.items, (candidate) => candidate.key === key && !candidate.isDisabled) + Array.some(items, (candidate) => candidate.key === key && !candidate.isDisabled) ? selected(model, key) : { model }, ), From b64ea79e56d0e1ab9407f72bd5da8f9600a96f4a Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:14:23 +0200 Subject: [PATCH 18/52] refactor(web): ComboBox items come from the parent The ComboBox Model keeps only the selection, the typed text and the popup. update takes the items as an argument and the view reads them from ViewInputs; TimezoneSelect owns the timezone list and its update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/page/my-settings/profile/update.ts | 2 +- apps/web-foldkit/src/ui/combo-box-view.ts | 4 +- .../src/ui/combo-box.scene.test.ts | 8 +- .../src/ui/combo-box.story.test.ts | 23 +++--- apps/web-foldkit/src/ui/combo-box.ts | 76 +++++++++---------- apps/web-foldkit/src/ui/timezone-select.ts | 15 +++- 6 files changed, 68 insertions(+), 60 deletions(-) diff --git a/apps/web-foldkit/src/page/my-settings/profile/update.ts b/apps/web-foldkit/src/page/my-settings/profile/update.ts index 06dacfa6d..3be68961e 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/update.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/update.ts @@ -163,7 +163,7 @@ const foldCropModal = (model: Model, message: Modal.Message): Return => { } const foldTimezone = (model: Model, message: ComboBox.Message): Return => { - const result = ComboBox.update(model.timezone, message) + const result = TimezoneSelect.update(model.timezone, message) const next = modifyFields(model, { timezone: () => result.model }) const commands = Command.mapMessages(result.commands ?? [], toTimezoneMessage) const key = result.outMessage?.key diff --git a/apps/web-foldkit/src/ui/combo-box-view.ts b/apps/web-foldkit/src/ui/combo-box-view.ts index 9aa0b5ef0..98ebdf624 100644 --- a/apps/web-foldkit/src/ui/combo-box-view.ts +++ b/apps/web-foldkit/src/ui/combo-box-view.ts @@ -108,6 +108,8 @@ const KeepTypedValue = Mount.define("KeepComboBoxTypedValue", { // VIEW export type ViewInputs = Readonly<{ + /** The options, owned by the parent (the same ones it passes to `update`). */ + items: ReadonlyArray<Item> /** The `Label` inside the ComboBox; TimezoneSelect has none. */ label?: string placeholder?: string @@ -294,7 +296,7 @@ const listbox = (model: Model, open: Open, viewInputs: ViewInputs, h: HtmlBuilde h.Id(listboxId(model.id)), h.Role("listbox"), ], - Array.map(visibleItems(model, open), (candidate) => option(model, open, candidate, h)), + Array.map(visibleItems(model, open, viewInputs.items), (candidate) => option(model, open, candidate, h)), ) const option = (model: Model, open: Open, candidate: Item, h: HtmlBuilder<Message>): Html => { diff --git a/apps/web-foldkit/src/ui/combo-box.scene.test.ts b/apps/web-foldkit/src/ui/combo-box.scene.test.ts index b412b7880..5f69f1fbe 100644 --- a/apps/web-foldkit/src/ui/combo-box.scene.test.ts +++ b/apps/web-foldkit/src/ui/combo-box.scene.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { AnnounceComboBox, FocusComboBoxInput, init, item, Message, OutMessage, update } from "./combo-box" +import { AnnounceComboBox, FocusComboBoxInput, init, item, Message, type Model, OutMessage, update } from "./combo-box" import { view } from "./combo-box-view" /** ComboBox through the view: combobox ARIA, virtual focus via aria-activedescendant, commit and revert. */ @@ -12,8 +12,8 @@ const items = [ item("engineering", "Engineering", true), item("cafe", "Café"), ] -const sceneView = Scene.withViewInputs(view, { label: "Channel", placeholder: "Pick a channel" }) -const config = { update, view: sceneView() } +const sceneView = Scene.withViewInputs(view, { items, label: "Channel", placeholder: "Pick a channel" }) +const config = { update: (model: Model, message: Message) => update(model, message, items), view: sceneView() } const input = Scene.role("combobox") const button = Scene.label("Show suggestions") const listbox = Scene.role("listbox") @@ -171,7 +171,7 @@ describe("combo box scene", () => { test("without a label the listbox is named by the suggestions button", () => { Scene.scene( - { update, view: sceneView({ label: undefined }) }, + { update: config.update, view: sceneView({ label: undefined }) }, Scene.given(init({ id: "channel", items, isAppleDevice: false })), mounted, Scene.expect(input).not.toHaveAttr("aria-labelledby"), diff --git a/apps/web-foldkit/src/ui/combo-box.story.test.ts b/apps/web-foldkit/src/ui/combo-box.story.test.ts index a3ef4d6d7..7483459e5 100644 --- a/apps/web-foldkit/src/ui/combo-box.story.test.ts +++ b/apps/web-foldkit/src/ui/combo-box.story.test.ts @@ -22,17 +22,18 @@ const items = [ item("cafe", "Café"), ] const comboBox = init({ id: "channel", items, isAppleDevice: false }) +const run = (model: Model, message: Message) => update(model, message, items) const inputKey = (key: string) => message(Message.PressedInputKey({ key })) const focusOf = (next: Model) => (next.popup._tag === "Open" ? next.popup.focusedKey : Option.none()) const visibleKeys = (next: Model) => - next.popup._tag === "Open" ? visibleItems(next, next.popup).map((found) => found.key) : [] + next.popup._tag === "Open" ? visibleItems(next, next.popup, items).map((found) => found.key) : [] const announced = (text: string) => Command.resolve(AnnounceComboBox({ message: text }), Message.CompletedAnnounce()) describe("combo box story", () => { test("typing filters the options ignoring accents, opens the list and announces the count", () => { story( - update, + run, given(comboBox), message(Message.ChangedInput({ value: "cafe" })), Command.expectExact(AnnounceComboBox({ message: "1 option available." })), @@ -46,7 +47,7 @@ describe("combo box story", () => { test("typing text that matches nothing closes the list", () => { story( - update, + run, given(comboBox), message(Message.ChangedInput({ value: "zzz" })), Command.expectNone(), @@ -59,7 +60,7 @@ describe("combo box story", () => { test("ArrowDown on a closed input shows every option and focuses the first, ArrowUp the last", () => { story( - update, + run, given(comboBox), inputKey("ArrowDown"), Command.expectNone(), @@ -75,7 +76,7 @@ describe("combo box story", () => { test("arrow keys skip the disabled option and stop at the ends", () => { story( - update, + run, given(comboBox), inputKey("ArrowDown"), inputKey("ArrowDown"), @@ -88,7 +89,7 @@ describe("combo box story", () => { test("Enter commits the focused option, closes the list and fills the input with its label", () => { story( - update, + run, given(comboBox), inputKey("ArrowUp"), inputKey("Enter"), @@ -103,7 +104,7 @@ describe("combo box story", () => { test("Enter with nothing focused closes the list and reverts the typed text", () => { story( - update, + run, given(init({ id: "channel", items, selectedKey: "design", isAppleDevice: false })), message(Message.ChangedInput({ value: "gen" })), announced("1 option available."), @@ -118,7 +119,7 @@ describe("combo box story", () => { test("Escape and blur both close the list and restore the selected label", () => { story( - update, + run, given(init({ id: "channel", items, selectedKey: "general", isAppleDevice: false })), message(Message.ChangedInput({ value: "des" })), announced("1 option available."), @@ -138,7 +139,7 @@ describe("combo box story", () => { test("the button toggles every option open on the selection and keeps focus in the input", () => { story( - update, + run, given(init({ id: "channel", items, selectedKey: "design", isAppleDevice: false })), message(Message.ChangedInput({ value: "Des" })), announced("1 option available."), @@ -158,7 +159,7 @@ describe("combo box story", () => { test("clicking a disabled option selects nothing, clicking an enabled one commits it", () => { story( - update, + run, given(comboBox), inputKey("ArrowDown"), message(Message.ClickedOption({ key: "engineering" })), @@ -172,7 +173,7 @@ describe("combo box story", () => { test("on Apple devices opening also announces the focused option for VoiceOver", () => { story( - update, + run, given(init({ id: "channel", items, selectedKey: "design", isAppleDevice: true })), inputKey("ArrowDown"), Command.expectExact( diff --git a/apps/web-foldkit/src/ui/combo-box.ts b/apps/web-foldkit/src/ui/combo-box.ts index 9a45b2e0f..f3b808d9d 100644 --- a/apps/web-foldkit/src/ui/combo-box.ts +++ b/apps/web-foldkit/src/ui/combo-box.ts @@ -6,8 +6,9 @@ import { modifyFields } from "foldkit/struct" import { announce } from "./aria/announcer" /** - * Port of `components/ui/combo-box.tsx` (React Aria ComboBox, menuTrigger "input") with string - * items. Focus stays in the input; the list uses virtual focus. The view is `combo-box-view.ts`. + * A combo box (input-triggered, string items). Focus stays in the input; the list uses virtual + * focus. The parent owns the items: it passes them to `update` and to the view (`combo-box-view.ts`), + * so the Model holds only the selection, the typed text and the popup. */ // MODEL @@ -31,7 +32,6 @@ type Open = Extract<typeof Popup.Type, { _tag: "Open" }> export const Model = Schema.Struct({ id: Schema.String, - items: Schema.Array(Item), selectedKey: Schema.Option(Schema.String), inputValue: Schema.String, isFocused: Schema.Boolean, @@ -54,6 +54,7 @@ const labelOf = (items: ReadonlyArray<Item>, key: Option.Option<string>): string () => "", ) +/** `items` only seed the input with the selected item's label. */ export const init = (config: { readonly id: string readonly items: ReadonlyArray<Item> @@ -63,7 +64,6 @@ export const init = (config: { const selectedKey = Option.fromNullishOr(config.selectedKey) return { id: config.id, - items: config.items, selectedKey, inputValue: labelOf(config.items, selectedKey), isFocused: false, @@ -114,10 +114,8 @@ const normalized = (text: string) => /** useFilter({ sensitivity: "base" }).contains */ const contains = (text: string, search: string) => normalized(text).includes(normalized(search)) -export const visibleItems = (model: Model, open: Open): ReadonlyArray<Item> => - open.isShowingAll - ? model.items - : Array.filter(model.items, (candidate) => contains(candidate.label, model.inputValue)) +export const visibleItems = (model: Model, open: Open, items: ReadonlyArray<Item>): ReadonlyArray<Item> => + open.isShowingAll ? items : Array.filter(items, (candidate) => contains(candidate.label, model.inputValue)) /** ListKeyboardDelegate without wrapping. */ const stepKey = (items: ReadonlyArray<Item>, from: Option.Option<string>, direction: 1 | -1) => { @@ -153,14 +151,13 @@ type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> const withPopup = (model: Model, popup: Model["popup"]): Model => modifyFields(model, { popup: () => popup }) -const closedAndReverted = (model: Model): Model => +const closedAndReverted = (model: Model, items: ReadonlyArray<Item>): Model => modifyFields(withPopup(model, { _tag: "Closed" }), { - inputValue: () => labelOf(model.items, model.selectedKey), + inputValue: () => labelOf(items, model.selectedKey), }) -const openedAll = (model: Model, strategy: "Selected" | "First" | "Last"): Model => { - const fallback = - strategy === "Last" ? stepKey(model.items, Option.none(), -1) : stepKey(model.items, Option.none(), 1) +const openedAll = (model: Model, items: ReadonlyArray<Item>, strategy: "Selected" | "First" | "Last"): Model => { + const fallback = strategy === "Last" ? stepKey(items, Option.none(), -1) : stepKey(items, Option.none(), 1) const focusedKey = strategy === "Selected" ? model.selectedKey : Option.orElse(model.selectedKey, () => fallback) return withPopup(model, { @@ -172,24 +169,24 @@ const openedAll = (model: Model, strategy: "Selected" | "First" | "Last"): Model }) } -const selected = (model: Model, key: string): UpdateReturn => ({ +const selected = (model: Model, items: ReadonlyArray<Item>, key: string): UpdateReturn => ({ model: modifyFields(withPopup(model, { _tag: "Closed" }), { selectedKey: () => Option.some(key), - inputValue: () => labelOf(model.items, Option.some(key)), + inputValue: () => labelOf(items, Option.some(key)), }), outMessage: OutMessage.ChangedSelection({ key }), }) -const pressedInputKey = (model: Model, key: string): UpdateReturn => { +const pressedInputKey = (model: Model, allItems: ReadonlyArray<Item>, key: string): UpdateReturn => { if (model.popup._tag === "Closed") return Match.value(key).pipe( - Match.when("ArrowDown", () => ({ model: openedAll(model, "First") })), - Match.when("ArrowUp", () => ({ model: openedAll(model, "Last") })), - Match.when("Escape", () => ({ model: closedAndReverted(model) })), + Match.when("ArrowDown", () => ({ model: openedAll(model, allItems, "First") })), + Match.when("ArrowUp", () => ({ model: openedAll(model, allItems, "Last") })), + Match.when("Escape", () => ({ model: closedAndReverted(model, allItems) })), Match.orElse(() => ({ model })), ) const open = model.popup - const items = visibleItems(model, open) + const items = visibleItems(model, open, allItems) const moved = (direction: 1 | -1) => ({ model: withPopup(model, { ...open, @@ -200,11 +197,11 @@ const pressedInputKey = (model: Model, key: string): UpdateReturn => { return Match.value(key).pipe( Match.when("ArrowDown", () => moved(1)), Match.when("ArrowUp", () => moved(-1)), - Match.when("Escape", () => ({ model: closedAndReverted(model) })), + Match.when("Escape", () => ({ model: closedAndReverted(model, allItems) })), Match.when("Enter", () => Option.match(open.focusedKey, { - onNone: () => ({ model: closedAndReverted(model) }), - onSome: (focusedKey) => selected(model, focusedKey), + onNone: () => ({ model: closedAndReverted(model, allItems) }), + onSome: (focusedKey) => selected(model, allItems, focusedKey), }), ), Match.orElse(() => ({ model })), @@ -221,20 +218,20 @@ export const AnnounceComboBox = Command.define("AnnounceComboBox", { const countAnnouncement = (count: number) => `${count} ${count === 1 ? "option" : "options"} available.` /** useComboBox's live announcements: option count, then (on Apple devices) focus and selection. */ -const announcementsFor = (previous: Model, next: Model): ReadonlyArray<string> => { +const announcementsFor = (previous: Model, next: Model, items: ReadonlyArray<Item>): ReadonlyArray<string> => { if (next.popup._tag !== "Open") { const isSelectionNew = Option.exists( next.selectedKey, (key) => !Option.contains(previous.selectedKey, key), ) return next.isAppleDevice && next.isFocused && isSelectionNew - ? [`${labelOf(next.items, next.selectedKey)}, selected`] + ? [`${labelOf(items, next.selectedKey)}, selected`] : [] } const open = next.popup - const count = visibleItems(next, open).length + const count = visibleItems(next, open, items).length const previousCount = - previous.popup._tag === "Open" ? visibleItems(previous, previous.popup).length : count + previous.popup._tag === "Open" ? visibleItems(previous, previous.popup, items).length : count const didOpen = previous.popup._tag === "Closed" && (Option.isNone(open.focusedKey) || next.isAppleDevice) const previousFocus = previous.popup._tag === "Open" ? previous.popup.focusedKey : Option.none<string>() const focusAnnouncement = Option.filter( @@ -243,7 +240,7 @@ const announcementsFor = (previous: Model, next: Model): ReadonlyArray<string> = ).pipe( Option.map( (key) => - `${labelOf(next.items, Option.some(key))}${Option.contains(next.selectedKey, key) ? ", selected" : ""}`, + `${labelOf(items, Option.some(key))}${Option.contains(next.selectedKey, key) ? ", selected" : ""}`, ), ) return [ @@ -252,8 +249,9 @@ const announcementsFor = (previous: Model, next: Model): ReadonlyArray<string> = ] } -export const update = (model: Model, message: Message): UpdateReturn => { - const result = updateComboBox(model, message) +/** `items` are the parent's, the same ones its view passes. */ +export const update = (model: Model, message: Message, items: ReadonlyArray<Item>): UpdateReturn => { + const result = updateComboBox(model, message, items) // NOTE: pressing an option moves the virtual focus to it before the selection lands. const pressedFocus = message._tag === "ClickedOption" && @@ -261,10 +259,10 @@ export const update = (model: Model, message: Message): UpdateReturn => { model.popup._tag === "Open" && !Option.contains(model.popup.focusedKey, message.key) ? [ - `${labelOf(model.items, Option.some(message.key))}${Option.contains(model.selectedKey, message.key) ? ", selected" : ""}`, + `${labelOf(items, Option.some(message.key))}${Option.contains(model.selectedKey, message.key) ? ", selected" : ""}`, ] : [] - const announcements = Array.map([...pressedFocus, ...announcementsFor(model, result.model)], (text) => + const announcements = Array.map([...pressedFocus, ...announcementsFor(model, result.model, items)], (text) => AnnounceComboBox({ message: text }), ) return Array.isReadonlyArrayNonEmpty(announcements) @@ -272,11 +270,11 @@ export const update = (model: Model, message: Message): UpdateReturn => { : result } -const updateComboBox = (model: Model, message: Message): UpdateReturn => +const updateComboBox = (model: Model, message: Message, items: ReadonlyArray<Item>): UpdateReturn => Message.match<UpdateReturn>(message, { ChangedInput: ({ value }) => { const typed = modifyFields(model, { inputValue: () => value }) - const matches = Array.filter(model.items, (candidate) => contains(candidate.label, value)) + const matches = Array.filter(items, (candidate) => contains(candidate.label, value)) return { model: Array.isReadonlyArrayNonEmpty(matches) ? withPopup(typed, { @@ -290,10 +288,10 @@ const updateComboBox = (model: Model, message: Message): UpdateReturn => } }, FocusedInput: () => ({ model: modifyFields(model, { isFocused: () => true }) }), - BlurredInput: () => ({ model: modifyFields(closedAndReverted(model), { isFocused: () => false }) }), - PressedInputKey: ({ key }) => pressedInputKey(model, key), + BlurredInput: () => ({ model: modifyFields(closedAndReverted(model, items), { isFocused: () => false }) }), + PressedInputKey: ({ key }) => pressedInputKey(model, items, key), PressedButton: () => ({ - model: model.popup._tag === "Open" ? closedAndReverted(model) : openedAll(model, "Selected"), + model: model.popup._tag === "Open" ? closedAndReverted(model, items) : openedAll(model, items, "Selected"), commands: [FocusComboBoxInput({ elementId: inputId(model.id) })], }), HoveredOption: ({ key }) => @@ -310,8 +308,8 @@ const updateComboBox = (model: Model, message: Message): UpdateReturn => } : { model }, ClickedOption: ({ key }) => - Array.some(model.items, (candidate) => candidate.key === key && !candidate.isDisabled) - ? selected(model, key) + Array.some(items, (candidate) => candidate.key === key && !candidate.isDisabled) + ? selected(model, items, key) : { model }, CompletedFocusInput: () => ({ model }), CompletedPortalComboBox: () => ({ model }), diff --git a/apps/web-foldkit/src/ui/timezone-select.ts b/apps/web-foldkit/src/ui/timezone-select.ts index 5b8837fd6..63614f92d 100644 --- a/apps/web-foldkit/src/ui/timezone-select.ts +++ b/apps/web-foldkit/src/ui/timezone-select.ts @@ -5,14 +5,20 @@ import * as ComboBox from "./combo-box" import { view as comboBoxView, type ViewInputs as ComboBoxViewInputs } from "./combo-box-view" /** - * Port of `components/ui/timezone-select.tsx`: a ComboBox over every IANA timezone. Embed it with - * `h.submodel({ view: TimezoneSelect.view, viewInputs: TimezoneSelect.viewInputs(className) })`; - * the chosen zone arrives as `ComboBox.OutMessage.ChangedSelection`. + * A ComboBox over every IANA timezone. Embed it with + * `h.submodel({ view: TimezoneSelect.view, viewInputs: TimezoneSelect.viewInputs(className) })` and + * fold it with `TimezoneSelect.update`; the chosen zone arrives as `ComboBox.OutMessage.ChangedSelection`. */ +export const items: ReadonlyArray<ComboBox.Item> = Array.map(getTimezones(), (zone) => + ComboBox.item(zone.id, zone.label), +) + +export const update = (model: ComboBox.Model, message: ComboBox.Message) => ComboBox.update(model, message, items) + export const init = (config: { readonly id: string; readonly value?: string }): ComboBox.Model => ComboBox.init({ id: config.id, - items: Array.map(getTimezones(), (zone) => ComboBox.item(zone.id, zone.label)), + items, selectedKey: config.value, // Read once at init so update stays pure; a platform Flag from the page is the follow-up. isAppleDevice: isAppleDevice(), @@ -21,6 +27,7 @@ export const init = (config: { readonly id: string; readonly value?: string }): export const viewInputs = ( options: { readonly className?: string; readonly placeholder?: string } = {}, ): ComboBoxViewInputs => ({ + items, placeholder: options.placeholder ?? "Select timezone...", className: options.className, listBoxClassName: "max-h-60", From 72eebee3b0ff7f6ee3c695307a6264401e4a92f6 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:15:28 +0200 Subject: [PATCH 19/52] fix(web): Create a new one on the org picker opens the workspace form instead of looping /onboarding sends an onboarded user (anyone on the picker) back to /, so the button never reached a form. It now opens /onboarding/setup-organization; a test enters the target route with an onboarded session and asserts no redirect. The organizations slice is a Loading | Redirected | Loaded union and rows are keyed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/page/initials.ts | 5 +++ .../src/page/select-organization/model.ts | 16 +++++++-- .../page/select-organization/scene.test.ts | 9 ++--- .../page/select-organization/story.test.ts | 35 +++++++++++++++---- .../src/page/select-organization/update.ts | 21 ++++++----- .../src/page/select-organization/view.ts | 31 ++++++++-------- 6 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 apps/web-foldkit/src/page/initials.ts diff --git a/apps/web-foldkit/src/page/initials.ts b/apps/web-foldkit/src/page/initials.ts new file mode 100644 index 000000000..deb6017a5 --- /dev/null +++ b/apps/web-foldkit/src/page/initials.ts @@ -0,0 +1,5 @@ +/** An avatar fallback for a name: the first letters of its first two words, else its first two letters. */ +export const initialsOf = (name: string): string => { + const [first, second] = name.trim().split(/\s+/) + return (second === undefined ? (first ?? "").slice(0, 2) : `${first?.charAt(0)}${second.charAt(0)}`).toUpperCase() +} diff --git a/apps/web-foldkit/src/page/select-organization/model.ts b/apps/web-foldkit/src/page/select-organization/model.ts index ca513fff4..ec33d6929 100644 --- a/apps/web-foldkit/src/page/select-organization/model.ts +++ b/apps/web-foldkit/src/page/select-organization/model.ts @@ -1,5 +1,6 @@ import { OrganizationId } from "@hazel/schema" import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" import * as CreateWorkspace from "../create-workspace" export const UserOrganization = Schema.Struct({ @@ -11,10 +12,19 @@ export const UserOrganization = Schema.Struct({ }) export type UserOrganization = typeof UserOrganization.Type +/** + * The membership live query: `Loading` until it is ready, `Redirected` once a single organization + * sent the user on (later emissions change nothing), otherwise the list to pick from. + */ +export const Organizations = defineTaggedUnion({ + Loading: {}, + Redirected: {}, + Loaded: { organizations: Schema.Array(UserOrganization) }, +}) +export type Organizations = typeof Organizations.Type + export const Model = Schema.Struct({ - /** `null` until the live query is ready. */ - organizations: Schema.NullOr(Schema.Array(UserOrganization)), - hasRedirected: Schema.Boolean, + organizations: Organizations, /** Shown when the user has no organization. */ createWorkspace: CreateWorkspace.Model, }) diff --git a/apps/web-foldkit/src/page/select-organization/scene.test.ts b/apps/web-foldkit/src/page/select-organization/scene.test.ts index d9458c149..ee3165cfc 100644 --- a/apps/web-foldkit/src/page/select-organization/scene.test.ts +++ b/apps/web-foldkit/src/page/select-organization/scene.test.ts @@ -7,7 +7,7 @@ import { makeShared, pageScene, uuid } from "../../test/pages-fixtures" import * as CreateWorkspace from "../create-workspace" import { PageOutMessage } from "../out-message" import { Message } from "./message" -import type { Model, UserOrganization } from "./model" +import { type Model, Organizations, type UserOrganization } from "./model" import { init, update } from "./update" import { view } from "./view" @@ -17,8 +17,7 @@ const shared = makeShared() const hazel: UserOrganization = { id: Schema.decodeSync(OrganizationId)(uuid(1)), name: "Hazel Labs", slug: "hazel", logoUrl: null, role: "owner" } const acme: UserOrganization = { id: Schema.decodeSync(OrganizationId)(uuid(2)), name: "Acme Corp", slug: null, logoUrl: null, role: "member" } const withOrganizations = (organizations: ReadonlyArray<UserOrganization>): Model => ({ - organizations: [...organizations], - hasRedirected: false, + organizations: Organizations.Loaded({ organizations: [...organizations] }), createWorkspace: CreateWorkspace.init(), }) @@ -35,7 +34,9 @@ describe("select organization view", () => { PageOutMessage.RequestedNavigation({ href: `/onboarding/setup-organization?orgId=${acme.id}`, replace: false }), ), Scene.click(Scene.role("button", { name: "Create a new one" })), - Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/onboarding", replace: false })), + Scene.expectOutMessage( + PageOutMessage.RequestedNavigation({ href: "/onboarding/setup-organization", replace: false }), + ), ) }) diff --git a/apps/web-foldkit/src/page/select-organization/story.test.ts b/apps/web-foldkit/src/page/select-organization/story.test.ts index 68be93203..f83df50b3 100644 --- a/apps/web-foldkit/src/page/select-organization/story.test.ts +++ b/apps/web-foldkit/src/page/select-organization/story.test.ts @@ -1,13 +1,15 @@ // @vitest-environment jsdom import { OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" +import { fromString } from "foldkit/url" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { uuid } from "../../test/pages-fixtures" +import { makeShared, uuid } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" +import { enterRoute } from "../registry" import { Message } from "./message" import type { UserOrganization } from "./model" -import { organizationHref } from "../../route" +import { organizationHref, urlToAppRoute } from "../../route" import { init, update } from "./update" /** `/select-organization`: a single organization redirects once; otherwise the user picks. */ @@ -30,7 +32,7 @@ describe("select organization", () => { given(init().model), message(Message.UpdatedOrganizations({ organizations: [hazel] })), expectOutMessage(navigation("/hazel")), - model((current) => expect(current.hasRedirected).toBe(true)), + model((current) => expect(current.organizations._tag).toBe("Redirected")), message(Message.UpdatedOrganizations({ organizations: [hazel] })), expectNoOutMessage(), Command.expectNone(), @@ -47,17 +49,36 @@ describe("select organization", () => { ) }) - test("several organizations wait for a pick; Create a new one starts onboarding", () => { + test("several organizations wait for a pick; Create a new one opens the workspace form", () => { story( update, given(init().model), message(Message.UpdatedOrganizations({ organizations: [hazel, unfinished] })), expectNoOutMessage(), - model((current) => expect(current.organizations).toHaveLength(2)), + model((current) => expect(current.organizations).toMatchObject({ _tag: "Loaded", organizations: [hazel, unfinished] })), message(Message.ClickedOrganization({ organization: hazel })), expectOutMessage(navigation("/hazel")), message(Message.ClickedCreateNew()), - expectOutMessage(navigation("/onboarding")), + expectOutMessage(navigation("/onboarding/setup-organization")), ) }) + + // Anyone on the picker is onboarded. `/onboarding` sent them straight back to `/` and into an + // organization, so "Create a new one" never reached a form. + test("Create a new one lands on a page that keeps an onboarded user", () => { + const onboarded = makeShared() + const picked = update( + update(init().model, Message.UpdatedOrganizations({ organizations: [hazel, unfinished] })).model, + Message.ClickedCreateNew(), + ) + const href = picked.outMessage?._tag === "RequestedNavigation" ? picked.outMessage.href : "" + const route = Option.match(fromString(`http://localhost${href}`), { + onNone: () => expect.unreachable(`unparseable ${href}`), + onSome: urlToAppRoute, + }) + expect(route._tag).toBe("OnboardingSetupOrganization") + const entered = enterRoute(null, route, onboarded) + expect(entered.slot?._tag).toBe("OnboardingSetupOrganization") + expect(Option.isNone(entered.outMessage)).toBe(true) + }) }) diff --git a/apps/web-foldkit/src/page/select-organization/update.ts b/apps/web-foldkit/src/page/select-organization/update.ts index 7ad9c9091..e37b94b4b 100644 --- a/apps/web-foldkit/src/page/select-organization/update.ts +++ b/apps/web-foldkit/src/page/select-organization/update.ts @@ -1,12 +1,12 @@ import { Option } from "effect" import { Command } from "foldkit" -import * as CreateWorkspace from "../create-workspace" import { modifyFields } from "foldkit/struct" import { AppRoute, hrefOf, organizationHref } from "../../route" import type { PageReturn } from "../contract" +import * as CreateWorkspace from "../create-workspace" import { PageOutMessage } from "../out-message" import { Message } from "./message" -import type { Model } from "./model" +import { type Model, Organizations } from "./model" type Return = PageReturn<Model, Message> @@ -16,22 +16,27 @@ const navigate = (model: Model, href: string): Return => ({ }) export const init = (): Return => ({ - model: { organizations: null, hasRedirected: false, createWorkspace: CreateWorkspace.init() }, + model: { organizations: Organizations.Loading(), createWorkspace: CreateWorkspace.init() }, }) export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { UpdatedOrganizations: ({ organizations }) => { - const next = modifyFields(model, { organizations: () => organizations }) + // The live query re-emits after the redirect; the page is already leaving. + if (model.organizations._tag === "Redirected") return { model } const [single] = organizations // `<Navigate>` with a single organization: there is nothing to pick. - return organizations.length === 1 && single && !model.hasRedirected - ? navigate(modifyFields(next, { hasRedirected: () => true }), organizationHref(single)) - : { model: next } + return organizations.length === 1 && single + ? navigate( + modifyFields(model, { organizations: () => Organizations.Redirected() }), + organizationHref(single), + ) + : { model: modifyFields(model, { organizations: () => Organizations.Loaded({ organizations }) }) } }, ClickedOrganization: ({ organization }) => navigate(model, organizationHref(organization)), + // Not `/onboarding`: it sends an onboarded user (anyone with organizations to pick) back to `/`. ClickedCreateNew: () => - navigate(model, hrefOf(AppRoute.Onboarding({ orgId: Option.none(), step: Option.none() }))), + navigate(model, hrefOf(AppRoute.OnboardingSetupOrganization({ orgId: Option.none() }))), GotCreateWorkspaceMessage: ({ message: child }) => { const result = CreateWorkspace.update(model.createWorkspace, child) return { diff --git a/apps/web-foldkit/src/page/select-organization/view.ts b/apps/web-foldkit/src/page/select-organization/view.ts index c189a8555..822b65d02 100644 --- a/apps/web-foldkit/src/page/select-organization/view.ts +++ b/apps/web-foldkit/src/page/select-organization/view.ts @@ -5,19 +5,13 @@ import { avatar } from "../../ui/avatar" import { button } from "../../ui/button" import type { PageViewInputs } from "../contract" import * as CreateWorkspace from "../create-workspace" +import { initialsOf } from "../initials" import { Message } from "./message" -import type { Model, UserOrganization } from "./model" +import { type Model, Organizations, type UserOrganization } from "./model" const toCreateWorkspaceMessage = (message: CreateWorkspace.Message): Message => Message.GotCreateWorkspaceMessage({ message }) -const initialsOf = (name: string) => - name - .split(" ") - .map((word) => word.charAt(0).toUpperCase()) - .slice(0, 2) - .join("") - const capitalize = (role: string) => role.charAt(0).toUpperCase() + role.slice(1) /** No organizations: create one here (previously a redirect to `/onboarding`, which looped). */ @@ -45,7 +39,8 @@ const createWorkspace = (h: HtmlBuilder<Message>, model: Model): Html => ) const organizationButton = (h: HtmlBuilder<Message>, organization: UserOrganization): Html => - h.button( + h.keyed("button")( + organization.id, [ h.Type("button"), h.OnClick(Message.ClickedOrganization({ organization })), @@ -150,10 +145,14 @@ const picker = (h: HtmlBuilder<Message>, organizations: ReadonlyArray<UserOrgani ], ) -export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => { - const organizations = model.organizations - // A single organization redirects, so it shows the loader like `<Navigate>` does. - if (shared.currentUser === null || organizations === null || organizations.length === 1) - return appLoader(h) - return organizations.length === 0 ? createWorkspace(h, model) : picker(h, organizations) -}) +export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => + shared.currentUser === null + ? appLoader(h) + : Organizations.match(model.organizations, { + Loading: () => appLoader(h), + // A single organization redirects, so it shows the loader like `<Navigate>` does. + Redirected: () => appLoader(h), + Loaded: ({ organizations }) => + organizations.length === 0 ? createWorkspace(h, model) : picker(h, organizations), + }), +) From c546e9c36344b1cb8b45591e7d2b1c81ea6e36e3 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:15:34 +0200 Subject: [PATCH 20/52] refactor(web): typed auth failure reasons and submission unions on the auth pages auth/flows.ts classifies every @yielded/auth and passkey-browser error with an exhaustive Match into AuthFlowError{reason} (no tag-suffix sniffing: a browser input rejection no longer reads as an expired sign-in) and matches the result's non-Authenticated variants exhaustively. The desktop handoff does the same with HandoffError{reason} and offers Try again on transient failures. Sign-in, register, continue and desktop pages hold one Submission/step union instead of pending/error/isSubmitting flags; the desktop page carries attemptId in its steps and opens the callback with load. isSessionLoading lives on contract.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/auth/desktop-handoff.ts | 86 ++++++-- apps/web-foldkit/src/auth/flows.test.ts | 33 +++ apps/web-foldkit/src/auth/flows.ts | 173 +++++++++++---- .../src/page/auth/continue/index.ts | 41 ++-- .../src/page/auth/continue/message.ts | 4 +- .../src/page/auth/continue/model.ts | 12 +- .../src/page/auth/desktop/index.ts | 207 ++++++++++-------- .../src/page/auth/desktop/scene.test.ts | 38 +++- apps/web-foldkit/src/page/auth/layout.ts | 64 +++++- .../src/page/auth/register/command.ts | 4 +- .../src/page/auth/register/message.ts | 3 +- .../src/page/auth/register/model.ts | 36 ++- .../src/page/auth/register/update.ts | 60 ++--- .../src/page/auth/register/view.ts | 53 +++-- apps/web-foldkit/src/page/auth/scene.test.ts | 36 ++- .../src/page/auth/sign-in/command.ts | 63 ++++-- .../src/page/auth/sign-in/message.ts | 5 +- .../src/page/auth/sign-in/model.ts | 30 ++- .../src/page/auth/sign-in/update.ts | 47 ++-- .../web-foldkit/src/page/auth/sign-in/view.ts | 28 ++- apps/web-foldkit/src/page/contract.ts | 4 + 21 files changed, 692 insertions(+), 335 deletions(-) create mode 100644 apps/web-foldkit/src/auth/flows.test.ts 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>()("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<typeof OperationHttpClient.Client> +type AuthApi = Context.Service.Shape<typeof HazelAuthClient>["auth"] +type SessionId = NonNullable<Effect.Success<ReturnType<AuthApi["getSession"]>>>["sessionId"] + +type HandoffCallError = + | Effect.Error<ReturnType<typeof callDescribe>> + | Effect.Error<ReturnType<typeof callAuthorize>> + | Effect.Error<ReturnType<AuthApi["getSession"]>> + +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>()("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<typeof HazelAuthClient>["auth"] + +/** Everything the auth client and the browser's passkey prompt can fail with. */ +type AuthError = + | Effect.Error<ReturnType<AuthApi["signIn"]>> + | Effect.Error<ReturnType<AuthApi["passkeySignIn"]>> + | Effect.Error<ReturnType<AuthApi["completePasskeySignIn"]>> + | Effect.Error<ReturnType<AuthApi["register"]>> + | 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<void, AuthFlowError> => + 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/page/auth/continue/index.ts b/apps/web-foldkit/src/page/auth/continue/index.ts index 8129280aa..9a2d840c3 100644 --- a/apps/web-foldkit/src/page/auth/continue/index.ts +++ b/apps/web-foldkit/src/page/auth/continue/index.ts @@ -1,11 +1,12 @@ import { Effect, Option, Schema } from "effect" import { Command, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" import { isProvider, Provider } from "../../../auth/client" -import { startOAuthSignIn } from "../../../auth/flows" +import { type AuthFailure, startOAuthSignIn } from "../../../auth/flows" import type { RouteOf } from "../../../route" import { loader } from "../../../ui/loader" import { definePage, type PageReturn, type PageViewInputs } from "../../contract" -import { authError, authFrame, authHeading, signInLink } from "../layout" +import { authError, authFailureMessage, authFrame, authHeading, signInLink } from "../layout" import { Message } from "./message" import { Model } from "./model" @@ -23,39 +24,43 @@ const ContinueSignIn = Command.define("ContinueSignIn", { execute: ({ provider }) => startOAuthSignIn(provider, null).pipe( Effect.as(Message.LeftForProvider()), - Effect.catch((error) => Effect.succeed(Message.FailedSignIn({ message: error.message }))), + Effect.catchTags({ AuthFlowError: ({ reason }) => Effect.succeed(Message.FailedSignIn({ reason })) }), ), }) const init = (route: RouteOf<"AuthContinue">): Return => { const provider = Option.getOrNull(route.provider) return provider !== null && isProvider(provider) - ? { model: { provider, error: null }, commands: [ContinueSignIn({ provider })] } - : { model: { provider: null, error: null } } + ? { model: Model.Redirecting({ provider }), commands: [ContinueSignIn({ provider })] } + : { model: Model.Invalid() } } const update = (model: Model, message: Message): Return => Message.match<Return>(message, { LeftForProvider: () => ({ model }), - FailedSignIn: ({ message }) => ({ - model: { ...model, error: message ?? "Something went wrong signing you in. Please try again." }, - }), + FailedSignIn: ({ reason }) => ({ model: Model.Failed({ reason }) }), }) +const failed = (h: HtmlBuilder<Message>, reason: AuthFailure | null): ReadonlyArray<Html> => [ + authHeading(h, "Couldn't finish signing in", "Start again and pick the same account."), + ...[reason === null ? null : authError(h, authFailureMessage(reason))].filter( + (html): html is Html => html !== null, + ), + h.p([h.Class("mt-4")], [signInLink(h, "Back to sign in")]), +] + const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => authFrame( h, shared.nowMs, - model.provider === null || model.error !== null - ? [ - authHeading(h, "Couldn't finish signing in", "Start again and pick the same account."), - authError(h, model.error), - h.p([h.Class("mt-4")], [signInLink(h, "Back to sign in")]), - ] - : [ - authHeading(h, "Your accounts are linked", "Finishing sign-in..."), - loader(h, { className: "size-6" }), - ], + Model.match(model, { + Invalid: () => failed(h, null), + Failed: ({ reason }) => failed(h, reason), + Redirecting: () => [ + authHeading(h, "Your accounts are linked", "Finishing sign-in..."), + loader(h, { className: "size-6" }), + ], + }), ), ) diff --git a/apps/web-foldkit/src/page/auth/continue/message.ts b/apps/web-foldkit/src/page/auth/continue/message.ts index b63ba0916..a74718824 100644 --- a/apps/web-foldkit/src/page/auth/continue/message.ts +++ b/apps/web-foldkit/src/page/auth/continue/message.ts @@ -1,8 +1,8 @@ -import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { AuthFailure } from "../../../auth/flows" export const Message = defineMessageUnion({ LeftForProvider: {}, - FailedSignIn: { message: Schema.NullOr(Schema.String) }, + FailedSignIn: { reason: AuthFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/auth/continue/model.ts b/apps/web-foldkit/src/page/auth/continue/model.ts index d4bb362ce..0d4656a86 100644 --- a/apps/web-foldkit/src/page/auth/continue/model.ts +++ b/apps/web-foldkit/src/page/auth/continue/model.ts @@ -1,8 +1,12 @@ import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { Provider } from "../../../auth/client" +import { AuthFailure } from "../../../auth/flows" -export const Model = Schema.Struct({ - /** `null` when `?provider=` is missing or not one the API offers. */ - provider: Schema.NullOr(Schema.String), - error: Schema.NullOr(Schema.String), +/** `Invalid`: `?provider=` is missing or not one the API offers. */ +export const Model = defineTaggedUnion({ + Invalid: {}, + Redirecting: { provider: Schema.Literals(Provider) }, + Failed: { reason: AuthFailure }, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/auth/desktop/index.ts b/apps/web-foldkit/src/page/auth/desktop/index.ts index d18d16934..94a49c054 100644 --- a/apps/web-foldkit/src/page/auth/desktop/index.ts +++ b/apps/web-foldkit/src/page/auth/desktop/index.ts @@ -1,15 +1,15 @@ import { Effect, Option, Schema } from "effect" import { Command, Submodel } from "foldkit" -import type { HtmlBuilder } from "foldkit/html" +import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { load } from "foldkit/navigation" import { defineTaggedUnion } from "foldkit/schema" -import { modifyFields } from "foldkit/struct" -import { authorizeAttempt, describeAttempt } from "../../../auth/desktop-handoff" +import { failureMessage, isRetryableReason, type UserErrorMessage } from "~/lib/error-messages" +import { authorizeAttempt, describeAttempt, HandoffFailure } from "../../../auth/desktop-handoff" import { AppRoute, hrefOf, signInHref, type RouteOf } from "../../../route" import { button } from "../../../ui/button" import { loader } from "../../../ui/loader" -import { definePage, type PageReturn, type PageViewInputs, type Shared } from "../../contract" +import { definePage, isSessionLoading, type PageReturn, type PageViewInputs, type Shared } from "../../contract" import { authError, authFrame, authHeading } from "../layout" /** @@ -17,25 +17,27 @@ import { authError, authFrame, authHeading } from "../layout" * confirms the account; Hazel then hands the desktop app its own session through its link. */ -const Step = defineTaggedUnion({ - Loading: {}, - Ready: { appName: Schema.String }, - Authorizing: { appName: Schema.String }, +/** The attempt id travels with every step that can still use it. */ +const Model = defineTaggedUnion({ + /** Opened without `?attempt=`: only the desktop app can start this. */ + NoAttempt: {}, + Loading: { attemptId: Schema.String }, + Ready: { attemptId: Schema.String, appName: Schema.String }, + Authorizing: { attemptId: Schema.String, appName: Schema.String }, /** The callback opened; kept for a manual "Open" link if the browser didn't follow it. */ Returned: { callbackUrl: Schema.String }, - Failed: { message: Schema.String }, + Failed: { attemptId: Schema.String, reason: HandoffFailure }, }) - -const Model = Schema.Struct({ attemptId: Schema.NullOr(Schema.String), step: Step }) type Model = typeof Model.Type const Message = defineMessageUnion({ DescribedAttempt: { appName: Schema.String }, - FailedDescribe: { message: Schema.String }, + FailedDescribe: { reason: HandoffFailure }, ClickedContinue: {}, ClickedSignIn: {}, + ClickedRetry: {}, AuthorizedAttempt: { callbackUrl: Schema.String }, - FailedAuthorize: { message: Schema.String }, + FailedAuthorize: { reason: HandoffFailure }, LeftForSignIn: {}, }) type Message = typeof Message.Type @@ -48,7 +50,7 @@ export const DescribeAttempt = Command.define("DescribeAttempt", { execute: ({ attemptId }) => describeAttempt(attemptId).pipe( Effect.map((description) => Message.DescribedAttempt({ appName: description.displayName })), - Effect.catch((error) => Effect.succeed(Message.FailedDescribe({ message: error.message }))), + Effect.catchTags({ HandoffError: ({ reason }) => Effect.succeed(Message.FailedDescribe({ reason })) }), ), }) @@ -58,75 +60,94 @@ export const AuthorizeAttempt = Command.define("AuthorizeAttempt", { messages: [Message.AuthorizedAttempt, Message.FailedAuthorize], execute: ({ attemptId }) => authorizeAttempt(attemptId).pipe( - Effect.tap((callbackUrl) => Effect.sync(() => window.location.assign(callbackUrl))), + Effect.tap(load), Effect.map((callbackUrl) => Message.AuthorizedAttempt({ callbackUrl })), - Effect.catch((error) => Effect.succeed(Message.FailedAuthorize({ message: error.message }))), + Effect.catchTags({ HandoffError: ({ reason }) => Effect.succeed(Message.FailedAuthorize({ reason })) }), ), }) /** Sign in in this browser, then come back here (the attempt lives for five minutes). */ -export const SignInFirst = Command.define("SignInForDesktop", { +export const SignInForDesktop = Command.define("SignInForDesktop", { args: { returnTo: Schema.String }, messages: [Message.LeftForSignIn], execute: ({ returnTo }) => load(signInHref(returnTo)).pipe(Effect.as(Message.LeftForSignIn())), }) -export const init = (route: RouteOf<"AuthDesktop">): Return => { - const attemptId = Option.getOrNull(route.attempt) - return attemptId === null - ? { - model: { - attemptId, - step: Step.Failed({ message: "Open this page from the Hazel desktop app." }), - }, - } - : { model: { attemptId, step: Step.Loading() }, commands: [DescribeAttempt({ attemptId })] } -} +export const init = (route: RouteOf<"AuthDesktop">): Return => + Option.match(route.attempt, { + onNone: () => ({ model: Model.NoAttempt() }), + onSome: (attemptId) => ({ + model: Model.Loading({ attemptId }), + commands: [DescribeAttempt({ attemptId })], + }), + }) + +const attemptIdOf = (model: Model): string | null => + Model.match(model, { + NoAttempt: () => null, + Loading: ({ attemptId }) => attemptId, + Ready: ({ attemptId }) => attemptId, + Authorizing: ({ attemptId }) => attemptId, + Returned: () => null, + Failed: ({ attemptId }) => attemptId, + }) + +/** A result for an attempt that is no longer loading or authorizing is stale. */ +const fail = (model: Model, reason: HandoffFailure, from: "Loading" | "Authorizing"): Return => + model._tag === from ? { model: Model.Failed({ attemptId: model.attemptId, reason }) } : { model } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - DescribedAttempt: ({ appName }) => ({ - model: modifyFields(model, { step: () => Step.Ready({ appName }) }), - }), - FailedDescribe: ({ message }) => ({ - model: modifyFields(model, { step: () => Step.Failed({ message }) }), - }), - ClickedContinue: () => { - const { step, attemptId } = model - return step._tag !== "Ready" || attemptId === null - ? { model } - : { - model: modifyFields(model, { - step: () => Step.Authorizing({ appName: step.appName }), - }), - commands: [AuthorizeAttempt({ attemptId })], + DescribedAttempt: ({ appName }) => + model._tag === "Loading" + ? { model: Model.Ready({ attemptId: model.attemptId, appName }) } + : { model }, + FailedDescribe: ({ reason }) => fail(model, reason, "Loading"), + ClickedContinue: () => + model._tag === "Ready" + ? { + model: Model.Authorizing({ attemptId: model.attemptId, appName: model.appName }), + commands: [AuthorizeAttempt({ attemptId: model.attemptId })], } - }, - ClickedSignIn: () => - model.attemptId === null + : { model }, + ClickedSignIn: () => { + const attemptId = attemptIdOf(model) + return attemptId === null ? { model } : { model, commands: [ - SignInFirst({ - returnTo: hrefOf( - AppRoute.AuthDesktop({ attempt: Option.some(model.attemptId) }), - ), + SignInForDesktop({ + returnTo: hrefOf(AppRoute.AuthDesktop({ attempt: Option.some(attemptId) })), }), ], - }, - AuthorizedAttempt: ({ callbackUrl }) => ({ - model: modifyFields(model, { step: () => Step.Returned({ callbackUrl }) }), - }), - // Authorizing is single use: a failure here means starting again from the app. - FailedAuthorize: ({ message }) => ({ - model: modifyFields(model, { step: () => Step.Failed({ message }) }), - }), + } + }, + // Only a transient failure is worth another try; the attempt itself is described again. + ClickedRetry: () => + model._tag === "Failed" && isRetryableHandoff(model.reason) + ? { + model: Model.Loading({ attemptId: model.attemptId }), + commands: [DescribeAttempt({ attemptId: model.attemptId })], + } + : { model }, + AuthorizedAttempt: ({ callbackUrl }) => + model._tag === "Authorizing" ? { model: Model.Returned({ callbackUrl }) } : { model }, + FailedAuthorize: ({ reason }) => fail(model, reason, "Authorizing"), LeftForSignIn: () => ({ model }), }) -const isSessionLoading = (shared: Shared) => - shared.auth === "Loading" || (shared.auth === "SignedIn" && shared.currentUser === null) +const isRetryableHandoff = (reason: HandoffFailure): boolean => + reason !== "Expired" && reason !== "NeedsSignIn" && isRetryableReason(reason) + +const handoffMessage = (reason: HandoffFailure): UserErrorMessage => + failureMessage(reason, { + Expired: { + title: "This sign-in request has expired or was already used", + description: "Start again from the desktop app.", + }, + NeedsSignIn: { title: "Sign in first, then continue" }, + }) const confirm = (h: HtmlBuilder<Message>, appName: string, shared: Shared, isAuthorizing: boolean) => shared.currentUser === null @@ -158,40 +179,50 @@ const confirm = (h: HtmlBuilder<Message>, appName: string, shared: Shared, isAut ), ] +const failed = (h: HtmlBuilder<Message>, reason: HandoffFailure | null): ReadonlyArray<Html> => [ + authHeading(h, "Couldn't sign in the app", "Start signing in again from the Hazel desktop app."), + ...(reason === null + ? [] + : [ + authError(h, handoffMessage(reason)) ?? h.empty, + ...(isRetryableHandoff(reason) + ? [ + button( + h, + { intent: "outline", className: "mt-4 w-full", onPress: Message.ClickedRetry() }, + ["Try again"], + ), + ] + : []), + ]), +] + export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => authFrame( h, shared.nowMs, - model.step._tag === "Loading" || isSessionLoading(shared) + isSessionLoading(shared) ? [loader(h, { className: "size-6" })] - : model.step._tag === "Ready" - ? confirm(h, model.step.appName, shared, false) - : model.step._tag === "Authorizing" - ? confirm(h, model.step.appName, shared, true) - : model.step._tag === "Returned" - ? [ - authHeading( - h, - "You're signed in", - "Switch back to the Hazel app. You can close this tab.", - ), - h.a( - [ - h.Class("text-fg text-sm underline underline-offset-2"), - h.Href(model.step.callbackUrl), - h.Attribute("rel", "noreferrer"), - ], - ["Open Hazel"], - ), - ] - : [ - authHeading( - h, - "Couldn't sign in the app", - "Start signing in again from the Hazel desktop app.", - ), - ...[authError(h, model.step.message)].filter((html) => html !== null), + : Model.match(model, { + NoAttempt: () => [ + authHeading(h, "Couldn't sign in the app", "Open this page from the Hazel desktop app."), + ], + Loading: () => [loader(h, { className: "size-6" })], + Ready: ({ appName }) => confirm(h, appName, shared, false), + Authorizing: ({ appName }) => confirm(h, appName, shared, true), + Returned: ({ callbackUrl }) => [ + authHeading(h, "You're signed in", "Switch back to the Hazel app. You can close this tab."), + h.a( + [ + h.Class("text-fg text-sm underline underline-offset-2"), + h.Href(callbackUrl), + h.Attribute("rel", "noreferrer"), ], + ["Open Hazel"], + ), + ], + Failed: ({ reason }) => failed(h, reason), + }), ), ) diff --git a/apps/web-foldkit/src/page/auth/desktop/scene.test.ts b/apps/web-foldkit/src/page/auth/desktop/scene.test.ts index 27fcc7c9c..1013e4c40 100644 --- a/apps/web-foldkit/src/page/auth/desktop/scene.test.ts +++ b/apps/web-foldkit/src/page/auth/desktop/scene.test.ts @@ -4,7 +4,7 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { AppRoute } from "../../../route" import { makeShared, pageScene } from "../../../test/pages-fixtures" -import { AuthorizeAttempt, init, SignInFirst, update, view } from "./index" +import { AuthorizeAttempt, DescribeAttempt, init, SignInForDesktop, update, view } from "./index" /** `/auth/desktop`: the browser confirms the account for the desktop app. */ @@ -12,10 +12,8 @@ const attempt = "a".repeat(43) const route = AppRoute.AuthDesktop({ attempt: Option.some(attempt) }) const signedOut = makeShared({ auth: "SignedOut", currentUser: null, organization: null, member: null }) const described = update(init(route).model, { _tag: "DescribedAttempt", appName: "Hazel Desktop" }).model -const expired = update(init(route).model, { - _tag: "FailedDescribe", - message: "This sign-in request has expired.", -}).model +const expired = update(init(route).model, { _tag: "FailedDescribe", reason: "Expired" }).model +const offline = update(init(route).model, { _tag: "FailedDescribe", reason: "Offline" }).model describe("desktop handoff page", () => { test("signed in, Continue authorizes the attempt and opens the app", () => { @@ -41,8 +39,8 @@ describe("desktop handoff page", () => { pageScene(update, view, signedOut), Scene.given(described), Scene.click(Scene.role("button", { name: "Sign in" })), - Scene.Command.expectExact(SignInFirst({ returnTo: `/auth/desktop?attempt=${attempt}` })), - Scene.Command.resolve(SignInFirst, { _tag: "LeftForSignIn" }), + Scene.Command.expectExact(SignInForDesktop({ returnTo: `/auth/desktop?attempt=${attempt}` })), + Scene.Command.resolve(SignInForDesktop, { _tag: "LeftForSignIn" }), ) }) @@ -51,7 +49,31 @@ describe("desktop handoff page", () => { pageScene(update, view, makeShared()), Scene.given(expired), Scene.expect(Scene.role("heading", { name: "Couldn't sign in the app" })).toExist(), - Scene.expect(Scene.role("alert")).toHaveText("This sign-in request has expired."), + Scene.expect(Scene.role("alert")).toHaveText( + "This sign-in request has expired or was already used. Start again from the desktop app.", + ), + Scene.expect(Scene.role("button", { name: "Try again" })).toBeAbsent(), + ) + }) + + test("an offline failure says so and describes the attempt again on Try again", () => { + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(offline), + Scene.expect(Scene.role("alert")).toHaveText("Connection lost. Check your internet connection and try again."), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(DescribeAttempt({ attemptId: attempt })), + Scene.Command.resolve(DescribeAttempt, { _tag: "DescribedAttempt", appName: "Hazel Desktop" }), + Scene.expect(Scene.role("heading", { name: "Continue to Hazel Desktop?" })).toExist(), + ) + }) + + test("a late describe result after a failure is ignored", () => { + const late = update(expired, { _tag: "DescribedAttempt", appName: "Hazel Desktop" }).model + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(late), + Scene.expect(Scene.role("heading", { name: "Couldn't sign in the app" })).toExist(), ) }) }) diff --git a/apps/web-foldkit/src/page/auth/layout.ts b/apps/web-foldkit/src/page/auth/layout.ts index 3a54e7e61..f210fb4bd 100644 --- a/apps/web-foldkit/src/page/auth/layout.ts +++ b/apps/web-foldkit/src/page/auth/layout.ts @@ -1,15 +1,29 @@ import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import type { AuthFailure } from "../../auth/flows" import { AppRoute, hrefOf } from "../../route" import { backgroundImage, logoContent, panelCard, panelFrame } from "../onboarding/brand-panel" -/** The auth pages' frame: the pixel-art panel on large screens (as on `/join`), the form beside it. */ +/** The auth pages' frame: the pixel-art panel on large screens, the form beside it. */ const homeLink = <M>(h: HtmlBuilder<M>, className: string, children: ReadonlyArray<Html>): Html => h.a([h.Class(className), h.Href(hrefOf(AppRoute.Root()))], [...children]) -export const authFrame = <M>(h: HtmlBuilder<M>, nowMs: number, content: ReadonlyArray<Html>): Html => - h.main( +const DEFAULT_TAGLINE = "A calm place for your team to talk, decide and ship." + +/** + * The frame with its own right-hand `card` (which must carry `m-auto`). `tagline` is the panel's + * text; `null` shows the panel without one. + */ +export const authShell = <M>( + h: HtmlBuilder<M>, + nowMs: number, + card: Html, + options: { readonly tagline?: string | null } = {}, +): Html => { + const tagline = options.tagline === undefined ? DEFAULT_TAGLINE : options.tagline + return h.main( [h.Class("grid h-dvh grid-cols-1 lg:grid-cols-2")], [ panelFrame(h, [ @@ -19,12 +33,7 @@ export const authFrame = <M>(h: HtmlBuilder<M>, nowMs: number, content: Readonly "relative z-20 flex items-center gap-2", logoContent(h, { logoClassName: "size-8 text-white" }), ), - panelCard(h, [ - h.p( - [h.Class("text-lg text-white")], - ["A calm place for your team to talk, decide and ship."], - ), - ]), + ...(tagline === null ? [] : [panelCard(h, [h.p([h.Class("text-lg text-white")], [tagline])])]), ]), h.div( [h.Class("flex h-full flex-col p-6 lg:p-12")], @@ -39,11 +48,15 @@ export const authFrame = <M>(h: HtmlBuilder<M>, nowMs: number, content: Readonly ), ], ), - h.div([h.Class("m-auto flex w-full max-w-sm flex-col")], [...content]), + card, ], ), ], ) +} + +export const authFrame = <M>(h: HtmlBuilder<M>, nowMs: number, content: ReadonlyArray<Html>): Html => + authShell(h, nowMs, h.div([h.Class("m-auto flex w-full max-w-sm flex-col")], [...content])) export const authHeading = <M>(h: HtmlBuilder<M>, title: string, description: string): Html => h.div( @@ -55,8 +68,35 @@ export const authHeading = <M>(h: HtmlBuilder<M>, title: string, description: st ) /** A failure under the form, announced to screen readers. */ -export const authError = <M>(h: HtmlBuilder<M>, message: string | null): Html | null => - message === null ? null : h.p([h.Class("mt-4 text-danger text-sm"), h.Role("alert")], [message]) +export const authError = <M>(h: HtmlBuilder<M>, message: UserErrorMessage | null): Html | null => + message === null + ? null + : h.p( + [h.Class("mt-4 text-danger text-sm"), h.Role("alert")], + [message.description === undefined ? message.title : `${message.title}. ${message.description}`], + ) + +/** What a failed sign-in says; nothing when the visitor cancelled it themselves. */ +export const authFailureMessage = (reason: AuthFailure): UserErrorMessage | null => + reason === "Cancelled" + ? null + : failureMessage<Exclude<AuthFailure, "Cancelled">>(reason, { + PasskeyUnsupported: { + title: "This browser can't use passkeys", + description: "Continue with GitHub or Google instead.", + }, + Rejected: { title: "Hazel couldn't verify that sign-in", description: "Please try again." }, + Expired: { + title: "That sign-in expired or was already used", + description: "Please start again.", + }, + Denied: { title: "This account can't sign in to Hazel" }, + AccountConflict: { + title: "That account is already linked to another Hazel user", + description: "Sign in with the account you used before.", + }, + AccountCreated: { title: "Your account is ready", description: "Sign in to continue." }, + }) export const signInLink = <M>(h: HtmlBuilder<M>, label: string): Html => h.a( diff --git a/apps/web-foldkit/src/page/auth/register/command.ts b/apps/web-foldkit/src/page/auth/register/command.ts index 38ec330d3..f045cf4a1 100644 --- a/apps/web-foldkit/src/page/auth/register/command.ts +++ b/apps/web-foldkit/src/page/auth/register/command.ts @@ -14,6 +14,8 @@ export const Register = Command.define("Register", { execute: (input) => register(input).pipe( Effect.as(Message.LeftForApp()), - Effect.catch((error) => Effect.succeed(Message.FailedRegistration({ message: error.message }))), + Effect.catchTags({ + AuthFlowError: ({ reason }) => Effect.succeed(Message.FailedRegistration({ reason })), + }), ), }) diff --git a/apps/web-foldkit/src/page/auth/register/message.ts b/apps/web-foldkit/src/page/auth/register/message.ts index 16948fb87..f55099d67 100644 --- a/apps/web-foldkit/src/page/auth/register/message.ts +++ b/apps/web-foldkit/src/page/auth/register/message.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { AuthFailure } from "../../../auth/flows" export const Message = defineMessageUnion({ ChangedFirstName: { value: Schema.String }, @@ -7,6 +8,6 @@ export const Message = defineMessageUnion({ SubmittedRegistration: {}, /** The account exists and the browser is loading the app. */ LeftForApp: {}, - FailedRegistration: { message: Schema.NullOr(Schema.String) }, + FailedRegistration: { reason: AuthFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/auth/register/model.ts b/apps/web-foldkit/src/page/auth/register/model.ts index 2fb8a616d..45379305e 100644 --- a/apps/web-foldkit/src/page/auth/register/model.ts +++ b/apps/web-foldkit/src/page/auth/register/model.ts @@ -1,16 +1,34 @@ import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { AuthFailure } from "../../../auth/flows" -export const Model = Schema.Struct({ - /** From the OAuth callback's redirect; either missing means the link is unusable. */ - flowId: Schema.NullOr(Schema.String), - reference: Schema.NullOr(Schema.String), +/** + * `Invalid`: a submit was blocked by a missing name, so field errors show from then on (not while the + * visitor is still typing). `Submitting` stays until the browser navigates away. + */ +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Invalid: {}, + Submitting: {}, + Failed: { reason: AuthFailure }, +}) +export type Submission = typeof Submission.Type + +export const Form = Schema.Struct({ + /** From the OAuth callback's redirect. */ + flowId: Schema.String, + reference: Schema.String, firstName: Schema.String, lastName: Schema.String, - /** Errors show after the first submit, not while the visitor is still typing. */ - hasSubmitted: Schema.Boolean, - isSubmitting: Schema.Boolean, - error: Schema.NullOr(Schema.String), + submission: Submission, +}) +export type Form = typeof Form.Type + +/** `BrokenLink`: the callback's `flowId` or `reference` is missing, so the link is unusable. */ +export const Model = defineTaggedUnion({ + BrokenLink: {}, + Ready: { form: Form }, }) export type Model = typeof Model.Type -export const isFirstNameMissing = (model: Model) => model.firstName.trim().length === 0 +export const isFirstNameMissing = (form: Form) => form.firstName.trim().length === 0 diff --git a/apps/web-foldkit/src/page/auth/register/update.ts b/apps/web-foldkit/src/page/auth/register/update.ts index 5e41e1be0..a8c7f26d4 100644 --- a/apps/web-foldkit/src/page/auth/register/update.ts +++ b/apps/web-foldkit/src/page/auth/register/update.ts @@ -4,34 +4,43 @@ import type { RouteOf } from "../../../route" import type { PageReturn } from "../../contract" import { Register } from "./command" import { Message } from "./message" -import { isFirstNameMissing, type Model } from "./model" +import { type Form, isFirstNameMissing, Model, Submission } from "./model" type Return = PageReturn<Model, Message> export const init = (route: RouteOf<"AuthRegister">): Return => ({ - model: { - flowId: Option.getOrNull(route.flowId), - reference: Option.getOrNull(route.reference), - firstName: "", - lastName: "", - hasSubmitted: false, - isSubmitting: false, - error: null, - }, + model: Option.match(Option.all({ flowId: route.flowId, reference: route.reference }), { + onNone: () => Model.BrokenLink(), + onSome: ({ flowId, reference }) => + Model.Ready({ + form: { flowId, reference, firstName: "", lastName: "", submission: Submission.NotSubmitted() }, + }), + }), }) -const submit = (model: Model): Return => { - const submitted = modifyFields(model, { hasSubmitted: () => true, error: () => null }) - if (model.isSubmitting || isFirstNameMissing(model) || model.flowId === null || model.reference === null) - return { model: submitted } +const withForm = (model: Model, f: (form: Form) => { form: Form; commands?: Return["commands"] }): Return => + Model.match<Return>(model, { + BrokenLink: () => ({ model }), + Ready: ({ form }) => { + const next = f(form) + return { model: Model.Ready({ form: next.form }), commands: next.commands } + }, + }) + +const setSubmission = (form: Form, submission: Submission): Form => + modifyFields(form, { submission: () => submission }) + +const submit = (form: Form) => { + if (form.submission._tag === "Submitting") return { form } + if (isFirstNameMissing(form)) return { form: setSubmission(form, Submission.Invalid()) } return { - model: modifyFields(submitted, { isSubmitting: () => true }), + form: setSubmission(form, Submission.Submitting()), commands: [ Register({ - flowId: model.flowId, - reference: model.reference, - firstName: model.firstName, - lastName: model.lastName, + flowId: form.flowId, + reference: form.reference, + firstName: form.firstName, + lastName: form.lastName, }), ], } @@ -39,12 +48,13 @@ const submit = (model: Model): Return => { export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - ChangedFirstName: ({ value }) => ({ model: modifyFields(model, { firstName: () => value }) }), - ChangedLastName: ({ value }) => ({ model: modifyFields(model, { lastName: () => value }) }), - SubmittedRegistration: () => submit(model), + ChangedFirstName: ({ value }) => + withForm(model, (form) => ({ form: modifyFields(form, { firstName: () => value }) })), + ChangedLastName: ({ value }) => + withForm(model, (form) => ({ form: modifyFields(form, { lastName: () => value }) })), + SubmittedRegistration: () => withForm(model, submit), // Stays busy until the browser navigates away. LeftForApp: () => ({ model }), - FailedRegistration: ({ message }) => ({ - model: modifyFields(model, { isSubmitting: () => false, error: () => message }), - }), + FailedRegistration: ({ reason }) => + withForm(model, (form) => ({ form: setSubmission(form, Submission.Failed({ reason })) })), }) diff --git a/apps/web-foldkit/src/page/auth/register/view.ts b/apps/web-foldkit/src/page/auth/register/view.ts index c3eabef85..0fc72687c 100644 --- a/apps/web-foldkit/src/page/auth/register/view.ts +++ b/apps/web-foldkit/src/page/auth/register/view.ts @@ -4,9 +4,9 @@ import { button } from "../../../ui/button" import { loader } from "../../../ui/loader" import { textField } from "../../../ui/text-field" import type { PageViewInputs } from "../../contract" -import { authError, authFrame, authHeading, signInLink } from "../layout" +import { authError, authFailureMessage, authFrame, authHeading, signInLink } from "../layout" import { Message } from "./message" -import { isFirstNameMissing, type Model } from "./model" +import { type Form, isFirstNameMissing, Model } from "./model" const toFirstName = (value: string) => Message.ChangedFirstName({ value }) const toLastName = (value: string) => Message.ChangedLastName({ value }) @@ -45,24 +45,28 @@ const nameField = ( ], ) -const form = (h: HtmlBuilder<Message>, model: Model): Html => - h.form( +const nameForm = (h: HtmlBuilder<Message>, form: Form): Html => { + const isSubmitting = form.submission._tag === "Submitting" + return h.form( [h.Class("flex flex-col gap-4"), h.OnSubmit(Message.SubmittedRegistration())], [ nameField(h, { id: "register-first-name", label: "First name", - value: model.firstName, + value: form.firstName, autocomplete: "given-name", isRequired: true, - error: model.hasSubmitted && isFirstNameMissing(model) ? "Enter your first name" : null, + error: + form.submission._tag !== "NotSubmitted" && isFirstNameMissing(form) + ? "Enter your first name" + : null, isAutoFocused: true, onInput: toFirstName, }), nameField(h, { id: "register-last-name", label: "Last name", - value: model.lastName, + value: form.lastName, autocomplete: "family-name", isRequired: false, error: null, @@ -74,15 +78,14 @@ const form = (h: HtmlBuilder<Message>, model: Model): Html => { intent: "primary", className: "mt-2 w-full", - isDisabled: model.isSubmitting, + isDisabled: isSubmitting, onPress: Message.SubmittedRegistration(), }, - model.isSubmitting - ? [loader(h, { className: "size-4" }), "Creating your account..."] - : ["Continue"], + isSubmitting ? [loader(h, { className: "size-4" }), "Creating your account..."] : ["Continue"], ), ], ) +} const brokenLink = (h: HtmlBuilder<Message>): ReadonlyArray<Html> => [ authHeading( @@ -93,19 +96,27 @@ const brokenLink = (h: HtmlBuilder<Message>): ReadonlyArray<Html> => [ signInLink(h, "Back to sign in"), ] +const registration = (h: HtmlBuilder<Message>, form: Form): ReadonlyArray<Html> => { + const failure = form.submission._tag === "Failed" ? authFailureMessage(form.submission.reason) : null + return [ + authHeading(h, "Welcome to Hazel", "What should your teammates call you?"), + nameForm(h, form), + ...(failure === null + ? [] + : [ + authError(h, failure) ?? h.empty, + h.p([h.Class("mt-2")], [signInLink(h, "Start sign-in again")]), + ]), + ] +} + export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => authFrame( h, shared.nowMs, - model.flowId === null || model.reference === null - ? brokenLink(h) - : [ - authHeading(h, "Welcome to Hazel", "What should your teammates call you?"), - form(h, model), - authError(h, model.error), - ...(model.error === null - ? [] - : [h.p([h.Class("mt-2")], [signInLink(h, "Start sign-in again")])]), - ], + Model.match(model, { + BrokenLink: () => brokenLink(h), + Ready: ({ form }) => registration(h, form), + }), ), ) diff --git a/apps/web-foldkit/src/page/auth/scene.test.ts b/apps/web-foldkit/src/page/auth/scene.test.ts index 6ff493a5f..ed6ab636f 100644 --- a/apps/web-foldkit/src/page/auth/scene.test.ts +++ b/apps/web-foldkit/src/page/auth/scene.test.ts @@ -51,13 +51,12 @@ describe("sign in", () => { Scene.given(signIn(Option.none())), Scene.click(passkey), Scene.Command.expectExact(StartPasskeySignIn({ returnTo: null })), - Scene.Command.resolve( - StartPasskeySignIn, - SignInMessage.FailedSignIn({ message: "Sign-in failed." }), + Scene.Command.resolve(StartPasskeySignIn, SignInMessage.FailedSignIn({ reason: "Offline" })), + Scene.expect(Scene.role("alert")).toHaveText( + "Connection lost. Check your internet connection and try again.", ), - Scene.expect(Scene.role("alert")).toHaveText("Sign-in failed."), Scene.click(passkey), - Scene.Command.resolve(StartPasskeySignIn, SignInMessage.FailedSignIn({ message: null })), + Scene.Command.resolve(StartPasskeySignIn, SignInMessage.FailedSignIn({ reason: "Cancelled" })), Scene.expect(Scene.role("alert")).toBeAbsent(), ) }) @@ -89,6 +88,20 @@ describe("register", () => { ) }) + test("a failed registration says why and offers to start again", () => { + Scene.scene( + pageScene(updateRegister, registerView, signedOut), + Scene.given(initRegister(route).model), + Scene.type(Scene.label("First name"), "Ada"), + Scene.click(Scene.role("button", { name: "Continue" })), + Scene.expect(Scene.role("button", { name: "Creating your account..." })).toBeDisabled(), + Scene.Command.resolve(Register, RegisterMessage.FailedRegistration({ reason: "Expired" })), + Scene.expect(Scene.role("alert")).toHaveText("That sign-in expired or was already used. Please start again."), + Scene.expect(Scene.role("link", { name: "Start sign-in again" })).toExist(), + Scene.expect(Scene.role("button", { name: "Continue" })).toBeEnabled(), + ) + }) + test("a missing first name blocks the submit", () => { Scene.scene( pageScene(updateRegister, registerView, signedOut), @@ -115,21 +128,24 @@ describe("register", () => { describe("desktop sign-in", () => { test("waits for the browser; Cancel stops the wait and retires the attempt", () => { const started = updateSignIn(signIn(Option.none()), SignInMessage.ClickedBrowserSignIn()) - expect(started.model.pending).toBe("browser") + expect(started.model.submission).toEqual({ _tag: "Pending", attempt: "browser" }) expect(started.commands?.map((command) => command.name)).toEqual([StartBrowserSignIn.name]) const cancelling = updateSignIn(started.model, SignInMessage.ClickedCancelBrowserSignIn()) expect(cancelling.commands).toHaveLength(1) const cancelled = updateSignIn(cancelling.model, SignInMessage.CompletedCancelBrowserSignIn()) - expect(cancelled.model.pending).toBeNull() + expect(cancelled.model.submission._tag).toBe("Idle") expect(cancelled.commands?.map((command) => command.name)).toEqual([RetireBrowserSignIn.name]) }) test("a failed browser sign-in says why and can be retried", () => { const started = updateSignIn(signIn(Option.none()), SignInMessage.ClickedBrowserSignIn()).model - const failed = updateSignIn(started, SignInMessage.FailedSignIn({ message: "Signing in took too long." })) - expect(failed.model).toMatchObject({ pending: null, error: "Signing in took too long." }) - expect(updateSignIn(failed.model, SignInMessage.ClickedBrowserSignIn()).model.pending).toBe("browser") + const failed = updateSignIn(started, SignInMessage.FailedSignIn({ reason: "BrowserTimedOut" })) + expect(failed.model.submission).toEqual({ _tag: "Failed", reason: "BrowserTimedOut" }) + expect(updateSignIn(failed.model, SignInMessage.ClickedBrowserSignIn()).model.submission).toEqual({ + _tag: "Pending", + attempt: "browser", + }) }) }) diff --git a/apps/web-foldkit/src/page/auth/sign-in/command.ts b/apps/web-foldkit/src/page/auth/sign-in/command.ts index 0ae4234f4..e4e3781c3 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/command.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/command.ts @@ -1,40 +1,54 @@ -import { Effect, Schema } from "effect" +import type { DesktopAuth } from "@hazel/auth/desktop" +import { Effect, Match, Schema } from "effect" import { Command } from "foldkit" -import { Provider } from "../../../auth/client" -import { signInWithPasskey, startOAuthSignIn } from "../../../auth/flows" import { load } from "foldkit/navigation" +import { Provider } from "../../../auth/client" +import { operationReasons, signInWithPasskey, startOAuthSignIn } from "../../../auth/flows" import { runDesktopAuth } from "../../../platform/desktop-auth" import { Message } from "./message" +import type { SignInFailure } from "./model" -const settle = <R>(flow: Effect.Effect<void, { readonly message: string | null }, R>) => - flow.pipe( - Effect.as(Message.LeftForSignIn()), - Effect.catch((error) => Effect.succeed(Message.FailedSignIn({ message: error.message }))), - ) +const failedSignIn = (error: { readonly reason: SignInFailure }) => + Effect.succeed(Message.FailedSignIn({ reason: error.reason })) export const StartOAuthSignIn = Command.define("StartOAuthSignIn", { args: { provider: Schema.Literals(Provider), returnTo: Schema.NullOr(Schema.String) }, messages: [Message.LeftForSignIn, Message.FailedSignIn], - execute: ({ provider, returnTo }) => settle(startOAuthSignIn(provider, returnTo)), + execute: ({ provider, returnTo }) => + startOAuthSignIn(provider, returnTo).pipe( + Effect.as(Message.LeftForSignIn()), + Effect.catchTags({ AuthFlowError: failedSignIn }), + ), }) export const StartPasskeySignIn = Command.define("StartPasskeySignIn", { args: { returnTo: Schema.NullOr(Schema.String) }, messages: [Message.LeftForSignIn, Message.FailedSignIn], - execute: ({ returnTo }) => settle(signInWithPasskey(returnTo)), + execute: ({ returnTo }) => + signInWithPasskey(returnTo).pipe( + Effect.as(Message.LeftForSignIn()), + Effect.catchTags({ AuthFlowError: failedSignIn }), + ), }) -/** What a failed desktop sign-in tells the user (the library's `PlatformError` reasons). */ -const browserFailure = (error: { readonly _tag: string; readonly reason?: string }): string => - error._tag === "BrowserLoginIndeterminate" - ? "Couldn't confirm the last sign-in. Restart Hazel and try again." - : error.reason === "expired" - ? "Signing in took too long. Please try again." - : error.reason === "busy" - ? "A sign-in is already in progress in your browser." - : error._tag === "OperationHttpError" - ? "Couldn't reach Hazel. Check your connection and try again." - : "Couldn't sign in through the browser. Please try again." +type BrowserSignInError = Effect.Error<DesktopAuth["signIn"]> + +/** Exhaustive over the desktop client's sign-in failures (the library's `PlatformError` reasons first). */ +const browserSignInReason = (error: BrowserSignInError): SignInFailure => + Match.valueTags(error, { + ...operationReasons, + BrowserLoginPlatformError: ({ reason }): SignInFailure => + reason === "expired" + ? "BrowserTimedOut" + : reason === "busy" + ? "BrowserBusy" + : reason === "cancelled" + ? "Cancelled" + : "Unavailable", + BrowserLoginIndeterminate: (): SignInFailure => "BrowserIndeterminate", + BrowserLoginInvalid: (): SignInFailure => "Expired", + BrowserLoginUnavailable: (): SignInFailure => "Unavailable", + }) /** * Desktop: opens the browser at `/auth/desktop`, waits for its link back, then reloads into @@ -45,8 +59,10 @@ export const StartBrowserSignIn = Command.define("StartBrowserSignIn", { interrupt: true, execute: runDesktopAuth((auth) => auth.signIn).pipe( Effect.andThen(load("/")), - Effect.as(Message.LeftForSignIn()), - Effect.catch((error) => Effect.succeed(Message.FailedSignIn({ message: browserFailure(error) }))), + Effect.match({ + onSuccess: () => Message.LeftForSignIn(), + onFailure: (error) => Message.FailedSignIn({ reason: browserSignInReason(error) }), + }), ), }) @@ -54,6 +70,7 @@ export const StartBrowserSignIn = Command.define("StartBrowserSignIn", { export const RetireBrowserSignIn = Command.define("RetireBrowserSignIn", { messages: [Message.CompletedRetireBrowserSignIn], execute: runDesktopAuth((auth) => auth.cancel).pipe( + // Best effort: an attempt that can't be retired expires on its own. Effect.ignore, Effect.as(Message.CompletedRetireBrowserSignIn()), ), diff --git a/apps/web-foldkit/src/page/auth/sign-in/message.ts b/apps/web-foldkit/src/page/auth/sign-in/message.ts index ef9de7af6..a194b8892 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/message.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/message.ts @@ -1,14 +1,15 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { Provider } from "../../../auth/client" +import { SignInFailure } from "./model" export const Message = defineMessageUnion({ ClickedProvider: { provider: Schema.Literals(Provider) }, ClickedPasskey: {}, /** The browser is leaving for the provider or the app; nothing to render. */ LeftForSignIn: {}, - /** `message` is `null` when the visitor cancelled the passkey prompt. */ - FailedSignIn: { message: Schema.NullOr(Schema.String) }, + /** `Cancelled` when the visitor closed the passkey prompt themselves. */ + FailedSignIn: { reason: SignInFailure }, /** Desktop: sign in through the system browser. */ ClickedBrowserSignIn: {}, ClickedCancelBrowserSignIn: {}, diff --git a/apps/web-foldkit/src/page/auth/sign-in/model.ts b/apps/web-foldkit/src/page/auth/sign-in/model.ts index f50bd7ea7..2f6004205 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/model.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/model.ts @@ -1,18 +1,36 @@ import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" import { Provider } from "../../../auth/client" +import { AuthFailure } from "../../../auth/flows" /** What the visitor started: a provider's OAuth redirect, the passkey prompt, or (desktop) the browser. */ -export const Pending = Schema.NullOr( - Schema.Union([Schema.Literals(Provider), Schema.Literals(["passkey", "browser"])]), -) -export type Pending = typeof Pending.Type +export const Attempt = Schema.Union([Schema.Literals(Provider), Schema.Literals(["passkey", "browser"])]) +export type Attempt = typeof Attempt.Type + +/** Desktop sign-in through the system browser can also time out, meet another attempt, or end unsure. */ +export const SignInFailure = Schema.Union([ + AuthFailure, + Schema.Literals(["BrowserTimedOut", "BrowserBusy", "BrowserIndeterminate"]), +]) +export type SignInFailure = typeof SignInFailure.Type + +export const Submission = defineTaggedUnion({ + Idle: {}, + /** Stays pending until the browser navigates away. */ + Pending: { attempt: Attempt }, + Failed: { reason: SignInFailure }, +}) +export type Submission = typeof Submission.Type export const Model = Schema.Struct({ /** `/sign-up` shows the same choices: the first OAuth sign-in creates the account. */ mode: Schema.Literals(["SignIn", "SignUp"]), /** `?redirect_url=`: where to go once signed in. */ redirectUrl: Schema.NullOr(Schema.String), - pending: Pending, - error: Schema.NullOr(Schema.String), + submission: Submission, }) export type Model = typeof Model.Type + +/** The attempt in flight, if any. */ +export const pendingAttempt = (model: Model): Attempt | null => + model.submission._tag === "Pending" ? model.submission.attempt : null diff --git a/apps/web-foldkit/src/page/auth/sign-in/update.ts b/apps/web-foldkit/src/page/auth/sign-in/update.ts index 25b5bf0ba..1f61dd3d9 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/update.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/update.ts @@ -1,11 +1,13 @@ import { Option } from "effect" +import type { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import { safeReturnTo } from "../../../auth/return-to" import type { RouteOf } from "../../../route" +import type { Resources } from "../../../rpc" import type { PageReturn } from "../../contract" import { RetireBrowserSignIn, StartBrowserSignIn, StartOAuthSignIn, StartPasskeySignIn } from "./command" import { Message } from "./message" -import type { Model } from "./model" +import { type Attempt, type Model, pendingAttempt, Submission } from "./model" type Return = PageReturn<Model, Message> @@ -13,46 +15,37 @@ export const init = (route: RouteOf<"SignIn" | "SignUp">): Return => ({ model: { mode: route._tag, redirectUrl: safeReturnTo(Option.getOrNull(route.redirectUrl)), - pending: null, - error: null, + submission: Submission.Idle(), }, }) +/** One attempt at a time: a press while another is pending does nothing. */ +const start = (model: Model, attempt: Attempt, command: Command.Command<Message, never, Resources>): Return => + pendingAttempt(model) !== null + ? { model } + : { + model: modifyFields(model, { submission: () => Submission.Pending({ attempt }) }), + commands: [command], + } + export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { ClickedProvider: ({ provider }) => - model.pending !== null - ? { model } - : { - model: modifyFields(model, { pending: () => provider, error: () => null }), - commands: [StartOAuthSignIn({ provider, returnTo: model.redirectUrl })], - }, - ClickedPasskey: () => - model.pending !== null - ? { model } - : { - model: modifyFields(model, { pending: () => "passkey" as const, error: () => null }), - commands: [StartPasskeySignIn({ returnTo: model.redirectUrl })], - }, - ClickedBrowserSignIn: () => - model.pending !== null - ? { model } - : { - model: modifyFields(model, { pending: () => "browser" as const, error: () => null }), - commands: [StartBrowserSignIn()], - }, + start(model, provider, StartOAuthSignIn({ provider, returnTo: model.redirectUrl })), + ClickedPasskey: () => start(model, "passkey", StartPasskeySignIn({ returnTo: model.redirectUrl })), + ClickedBrowserSignIn: () => start(model, "browser", StartBrowserSignIn()), ClickedCancelBrowserSignIn: () => - model.pending !== "browser" + pendingAttempt(model) !== "browser" ? { model } : { model, commands: [StartBrowserSignIn.Interrupt(() => Message.CompletedCancelBrowserSignIn())] }, CompletedCancelBrowserSignIn: () => ({ - model: modifyFields(model, { pending: () => null }), + model: modifyFields(model, { submission: () => Submission.Idle() }), commands: [RetireBrowserSignIn()], }), CompletedRetireBrowserSignIn: () => ({ model }), // The page stays busy until the browser navigates away. LeftForSignIn: () => ({ model }), - FailedSignIn: ({ message }) => ({ - model: modifyFields(model, { pending: () => null, error: () => message }), + FailedSignIn: ({ reason }) => ({ + model: modifyFields(model, { submission: () => Submission.Failed({ reason }) }), }), }) diff --git a/apps/web-foldkit/src/page/auth/sign-in/view.ts b/apps/web-foldkit/src/page/auth/sign-in/view.ts index db1354dc9..dd1c59ecd 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/view.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/view.ts @@ -8,9 +8,10 @@ import { AppRoute, hrefOf } from "../../../route" import { button } from "../../../ui/button" import { loader } from "../../../ui/loader" import type { PageViewInputs } from "../../contract" -import { authError, authFrame, authHeading } from "../layout" +import type { UserErrorMessage } from "~/lib/error-messages" +import { authError, authFailureMessage, authFrame, authHeading } from "../layout" import { Message } from "./message" -import type { Model, Pending } from "./model" +import { type Attempt, type Model, pendingAttempt, type SignInFailure } from "./model" const providers: ReadonlyArray<{ readonly id: Provider @@ -54,7 +55,7 @@ const divider = (h: HtmlBuilder<Message>): Html => [h.div([h.Class("h-px flex-1 bg-border")]), "or", h.div([h.Class("h-px flex-1 bg-border")])], ) -const choices = (h: HtmlBuilder<Message>, pending: Pending): Html => +const choices = (h: HtmlBuilder<Message>, pending: Attempt | null): Html => h.div( [h.Class("flex flex-col gap-3")], [ @@ -98,10 +99,23 @@ const switchLink = (h: HtmlBuilder<Message>, model: Model): Html => { ) } +/** What a failed sign-in says, including the desktop browser's own failures. */ +const signInFailureMessage = (reason: SignInFailure): UserErrorMessage | null => + reason === "BrowserTimedOut" + ? { title: "Signing in took too long", description: "Please try again." } + : reason === "BrowserBusy" + ? { title: "A sign-in is already in progress in your browser" } + : reason === "BrowserIndeterminate" + ? { title: "Couldn't confirm the last sign-in", description: "Restart Hazel and try again." } + : authFailureMessage(reason) + +const failureOf = (model: Model): UserErrorMessage | null => + model.submission._tag === "Failed" ? signInFailureMessage(model.submission.reason) : null + /** The desktop app signs in through the system browser, where GitHub, Google and passkeys work. */ const desktopChoices = (h: HtmlBuilder<Message>, model: Model): ReadonlyArray<Html> => [ authHeading(h, "Sign in to Hazel", "Continue in your browser, then come back here."), - model.pending === "browser" + pendingAttempt(model) === "browser" ? h.div( [h.Class("flex flex-col gap-3")], [ @@ -118,7 +132,7 @@ const desktopChoices = (h: HtmlBuilder<Message>, model: Model): ReadonlyArray<Ht IconExternalLink(h, { className: "size-4", attributes: { "aria-hidden": "true" } }), "Sign in with your browser", ]), - ...[authError(h, model.error)].filter((html): html is Html => html !== null), + ...[authError(h, failureOf(model))].filter((html): html is Html => html !== null), ] export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => @@ -132,8 +146,8 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, "Create your Hazel account", "Sign up with GitHub or Google. It takes a few seconds.", ), - choices(h, model.pending), - authError(h, model.error), + choices(h, pendingAttempt(model)), + authError(h, failureOf(model)), switchLink(h, model), ]), ) diff --git a/apps/web-foldkit/src/page/contract.ts b/apps/web-foldkit/src/page/contract.ts index 8397c000b..fa0a4b3b0 100644 --- a/apps/web-foldkit/src/page/contract.ts +++ b/apps/web-foldkit/src/page/contract.ts @@ -37,6 +37,10 @@ export interface Shared { export const can = (shared: Shared, action: RpcActionName): boolean => shared.member !== null && canPerform(RPC_SCOPE_MAP, shared.member.role, action) +/** `useAuth().isLoading`: the session check is pending, or signed in while `user.me` is pending. */ +export const isSessionLoading = (shared: Shared): boolean => + shared.auth === "Loading" || (shared.auth === "SignedIn" && shared.currentUser === null) + export interface PageViewInputs { readonly shared: Shared } From faa2a1df20fd6e92bf02bfa21f99c02dec42c211 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:16:19 +0200 Subject: [PATCH 21/52] refactor(web): date segments keep their locale in the Model with cached formatters Segments.init takes the locale (default en-US, like the calendar) and stores it, so update and view never read navigator.language. Intl formatters and field names are built once per locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/ui/date-field.ts | 13 ++-- apps/web-foldkit/src/ui/date-segments.test.ts | 25 ++++++- apps/web-foldkit/src/ui/date-segments.ts | 65 ++++++++++++++----- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/web-foldkit/src/ui/date-field.ts b/apps/web-foldkit/src/ui/date-field.ts index 250e7127b..ab9aa65d3 100644 --- a/apps/web-foldkit/src/ui/date-field.ts +++ b/apps/web-foldkit/src/ui/date-field.ts @@ -34,10 +34,8 @@ export interface DateFieldParts { export const hiddenInputStyle = "border: 0px; clip: rect(0px, 0px, 0px, 0px); clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; width: 1px; white-space: nowrap; top: 0px; left: 0px;" -const fieldName = (type: Segments.SegmentType) => - new Intl.DisplayNames(navigator.language, { type: "dateTimeField" }).of( - type === "dayPeriod" ? "dayPeriod" : type, - ) ?? type +const fieldName = (locale: string, type: Segments.SegmentType) => + Segments.formattersFor(locale).fieldNames.of(type) ?? type /** useDateSegment's name: the field's aria-label after a comma, a trailing comma when labelled by an element. */ const segmentName = (name: string, ariaLabel: string | undefined, labelledBy: string | undefined) => @@ -47,9 +45,10 @@ const segmentName = (name: string, ariaLabel: string | undefined, labelledBy: st export const selectedDescription = (model: Segments.Model) => { if (model.committed === null) return null const date = Segments.dateValueOf(model) + const formatters = Segments.formattersFor(model.locale) return model.kind === "date" - ? `Selected Date: ${new Intl.DateTimeFormat(navigator.language, { dateStyle: "long", timeZone: "UTC" }).format(date)}` - : `Selected Time: ${new Intl.DateTimeFormat(navigator.language, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(date)}` + ? `Selected Date: ${formatters.longDate.format(date)}` + : `Selected Time: ${formatters.time.format(date)}` } /** The DateInput's segments: contenteditable spinbuttons plus aria-hidden literals. */ @@ -96,7 +95,7 @@ export const dateSegments = <ParentMessage>( h.DataAttribute("type", type), ...statusAttributes, ...(part.isPlaceholder ? [h.DataAttribute("placeholder", "true")] : []), - h.AriaLabel(segmentName(fieldName(type), options.ariaLabel, options.labelledBy)), + h.AriaLabel(segmentName(fieldName(options.model.locale, type), options.ariaLabel, options.labelledBy)), ...(options.labelledBy === undefined ? [] : [h.AriaLabelledBy(`${id} ${options.labelledBy}`)]), diff --git a/apps/web-foldkit/src/ui/date-segments.test.ts b/apps/web-foldkit/src/ui/date-segments.test.ts index 2f4facb9e..7572aa3fb 100644 --- a/apps/web-foldkit/src/ui/date-segments.test.ts +++ b/apps/web-foldkit/src/ui/date-segments.test.ts @@ -1,7 +1,16 @@ import { Array } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { AnnounceSegmentValue, FocusSegment, init, Message, type Model, update } from "./date-segments" +import { + AnnounceSegmentValue, + FocusSegment, + formattersFor, + init, + Message, + type Model, + segmentOrder, + update, +} from "./date-segments" /** * useDateSegment typing: auto-advance never depends on the focus move having run. Story resolves @@ -61,3 +70,17 @@ describe("date segments", () => { ) }) }) + +describe("date segments locale", () => { + test("the locale is fixed in the Model at init and lays the segments out", () => { + const british = init({ id: "start", kind: "date", today: "2026-10-09", locale: "en-GB" }) + expect(british.locale).toBe("en-GB") + expect(segmentOrder(british.kind, british.locale)).toEqual(["day", "month", "year"]) + expect(empty.locale).toBe("en-US") + expect(segmentOrder(empty.kind, empty.locale)).toEqual(["month", "day", "year"]) + }) + + test("formatters are built once per locale", () => { + expect(formattersFor("en-GB")).toBe(formattersFor("en-GB")) + }) +}) diff --git a/apps/web-foldkit/src/ui/date-segments.ts b/apps/web-foldkit/src/ui/date-segments.ts index bb683b97b..93f1f4d10 100644 --- a/apps/web-foldkit/src/ui/date-segments.ts +++ b/apps/web-foldkit/src/ui/date-segments.ts @@ -38,6 +38,8 @@ const Placeholder = Schema.Struct({ export const Model = Schema.Struct({ id: Schema.String, kind: Kind, + /** The BCP 47 locale the segments are laid out and named in, fixed at init. */ + locale: Schema.String, isDisabled: Schema.Boolean, isInvalid: Schema.Boolean, values: Values, @@ -58,13 +60,17 @@ const pad = (value: number, length = 2) => String(value).padStart(length, "0") /** A time's placeholder date only fills the Intl formatting input, so a fixed day keeps init pure. */ const TIME_PLACEHOLDER_DATE = "2000-01-01" +/** The locale a field uses unless its parent passes one (the kit's placeholder table is English). */ +export const DEFAULT_LOCALE = "en-US" + /** * `value` is `YYYY-MM-DD` for a date and `HH:MM` for a time. A date's placeholder is `today` - * (`YYYY-MM-DD`), passed in so init never reads the clock. + * (`YYYY-MM-DD`), passed in so init never reads the clock; `locale` likewise never reads the host. */ export const init = ( options: { readonly id: string + readonly locale?: string readonly value?: string readonly isDisabled?: boolean readonly isInvalid?: boolean @@ -91,6 +97,7 @@ export const init = ( const model: Model = { id: options.id, kind: options.kind, + locale: options.locale ?? DEFAULT_LOCALE, isDisabled: options.isDisabled ?? false, isInvalid: options.isInvalid ?? false, values, @@ -106,15 +113,36 @@ export const init = ( // FORMATTING -const locale = () => (typeof navigator === "undefined" ? "en-US" : navigator.language) +/** The Intl formatters one locale needs, built once per locale (a pure memo, no app state). */ +export interface Formatters { + readonly date: Intl.DateTimeFormat + readonly time: Intl.DateTimeFormat + readonly monthName: Intl.DateTimeFormat + readonly hour: Intl.DateTimeFormat + readonly longDate: Intl.DateTimeFormat + readonly fieldNames: Intl.DisplayNames +} -const formatter = (kind: Kind) => - new Intl.DateTimeFormat(locale(), { - ...(kind === "date" - ? { year: "numeric", month: "numeric", day: "numeric" } - : { hour: "numeric", minute: "2-digit" }), - timeZone: "UTC", - }) +const formatterCache = new Map<string, Formatters>() + +export const formattersFor = (locale: string): Formatters => { + const cached = formatterCache.get(locale) + if (cached !== undefined) return cached + const utc = { timeZone: "UTC" } as const + const built: Formatters = { + date: new Intl.DateTimeFormat(locale, { year: "numeric", month: "numeric", day: "numeric", ...utc }), + time: new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", ...utc }), + monthName: new Intl.DateTimeFormat(locale, { month: "long", ...utc }), + hour: new Intl.DateTimeFormat(locale, { hour: "numeric", ...utc }), + longDate: new Intl.DateTimeFormat(locale, { dateStyle: "long", ...utc }), + fieldNames: new Intl.DisplayNames(locale, { type: "dateTimeField" }), + } + formatterCache.set(locale, built) + return built +} + +const formatter = (kind: Kind, locale: string) => + kind === "date" ? formattersFor(locale).date : formattersFor(locale).time /** IncompleteDate.toValue: fill missing segments from the placeholder, then constrain the day. */ export const dateValueOf = (model: Model): Date => { @@ -134,8 +162,8 @@ export const dateValueOf = (model: Model): Date => { return date } -export const segmentOrder = (kind: Kind): ReadonlyArray<SegmentType> => - formatter(kind) +export const segmentOrder = (kind: Kind, locale: string): ReadonlyArray<SegmentType> => + formatter(kind, locale) .formatToParts(new Date(Date.UTC(2000, 0, 1))) .map((part) => part.type) .filter(isSegmentType) @@ -158,7 +186,7 @@ const daysInDisplayedMonth = (model: Model) => { } const completeValue = (model: Model): string | null => { - const isComplete = segmentOrder(model.kind).every((type) => model.values[type] !== null) + const isComplete = segmentOrder(model.kind, model.locale).every((type) => model.values[type] !== null) if (!isComplete) return model.committed const date = dateValueOf(model) return model.kind === "date" @@ -192,9 +220,10 @@ const literal = (text: string): Segment => ({ */ export const segmentsOf = (model: Model): ReadonlyArray<Segment> => { const date = dateValueOf(model) - const monthName = new Intl.DateTimeFormat(locale(), { month: "long", timeZone: "UTC" }).format(date) - const hourText = new Intl.DateTimeFormat(locale(), { hour: "numeric", timeZone: "UTC" }).format(date) - return formatter(model.kind) + const formatters = formattersFor(model.locale) + const monthName = formatters.monthName.format(date) + const hourText = formatters.hour.format(date) + return formatter(model.kind, model.locale) .formatToParts(date) .flatMap((part): ReadonlyArray<Segment> => { if (part.type === "literal") return [literal(part.value)] @@ -334,7 +363,7 @@ const cycle = (model: Model, type: SegmentType, amount: number): Model => { } const neighbour = (model: Model, type: SegmentType, offset: number) => { - const order = segmentOrder(model.kind) + const order = segmentOrder(model.kind, model.locale) return order[order.indexOf(type) + offset] } @@ -411,7 +440,7 @@ const focusMovesOf = (result: Update.Return<Model, Message>) => /** The segment holding focus after an update: a FocusSegment target, else the pressed one. */ const focusedAfter = (model: Model, pressed: SegmentType, result: Update.Return<Model, Message>) => { const target = focusMovesOf(result).at(-1)?.args?.elementId - return segmentOrder(model.kind).find((type) => segmentId(model, type) === target) ?? pressed + return segmentOrder(model.kind, model.locale).find((type) => segmentId(model, type) === target) ?? pressed } /** @@ -420,7 +449,7 @@ const focusedAfter = (model: Model, pressed: SegmentType, result: Update.Return< */ const keySegment = (model: Model, pressed: string): SegmentType | undefined => { const name = model.pendingFocusMoves > 0 && model.activeSegment !== null ? model.activeSegment : pressed - return segmentOrder(model.kind).find((type) => type === name) + return segmentOrder(model.kind, model.locale).find((type) => type === name) } export const update = (model: Model, message: Message): Update.Return<Model, Message> => { From e5116e70ddc3431a6470507251fdb0eda7c59275 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:17:49 +0200 Subject: [PATCH 22/52] chore(web): drop legacy-sharing comments and delegate to Interaction.embed Style files no longer claim to be shared with the React app, comments stop pointing at apps/web, the date-fns re-export shim is gone, and the two hand-copied embedInteraction helpers call Interaction.embed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/chat/message/parts.ts | 2 +- apps/web-foldkit/src/chat/prism-modules.d.ts | 2 +- .../components/embeds/embed-markdown.parser.ts | 2 +- .../components/embeds/embed-markdown.styles.ts | 2 -- .../src/core/components/embeds/embed.styles.ts | 2 -- .../src/core/components/ui/badge.styles.ts | 1 - .../src/core/components/ui/button.styles.ts | 1 - .../src/core/components/ui/calendar.styles.ts | 2 -- .../src/core/components/ui/card.styles.ts | 1 - .../src/core/components/ui/choice-box.styles.ts | 1 - .../src/core/components/ui/combo-box.styles.ts | 1 - .../components/ui/command-menu-form.styles.ts | 2 +- .../core/components/ui/command-menu.styles.ts | 1 - .../src/core/components/ui/date-field.styles.ts | 1 - .../src/core/components/ui/date-picker.styles.ts | 2 -- .../src/core/components/ui/dialog.styles.ts | 2 +- .../src/core/components/ui/dropdown.styles.ts | 1 - .../src/core/components/ui/empty-state.styles.ts | 1 - .../src/core/components/ui/field.styles.ts | 1 - .../src/core/components/ui/input.styles.ts | 2 +- .../src/core/components/ui/keyboard.styles.ts | 1 - .../src/core/components/ui/link.styles.ts | 2 +- .../src/core/components/ui/loader.styles.ts | 1 - .../src/core/components/ui/menu.styles.ts | 1 - .../src/core/components/ui/modal.styles.ts | 1 - .../src/core/components/ui/popover.styles.ts | 1 - .../core/components/ui/progress-bar.styles.ts | 1 - .../src/core/components/ui/radio.styles.ts | 1 - .../core/components/ui/search-field.styles.ts | 1 - .../core/components/ui/section-header.styles.ts | 1 - .../core/components/ui/section-label.styles.ts | 1 - .../src/core/components/ui/select.styles.ts | 1 - .../src/core/components/ui/separator.styles.ts | 1 - .../src/core/components/ui/sheet.styles.ts | 1 - .../ui/split-panel/split-panel.styles.ts | 2 +- .../src/core/components/ui/tabs.styles.ts | 1 - .../src/core/components/ui/textarea.styles.ts | 2 +- .../src/core/components/ui/toast.styles.ts | 2 -- .../src/core/components/ui/toolbar.styles.ts | 1 - .../src/core/components/ui/tooltip.styles.ts | 1 - apps/web-foldkit/src/core/lib/date-fns-shared.ts | 2 -- apps/web-foldkit/src/data/storage.ts | 2 +- apps/web-foldkit/src/editor/markdown.test.ts | 2 +- apps/web-foldkit/src/page/chat/pinned.ts | 2 +- apps/web-foldkit/src/page/chat/thread-panel.ts | 2 +- apps/web-foldkit/src/page/my-settings/shared.ts | 16 +--------------- .../settings/integrations/shared/interaction.ts | 16 +--------------- apps/web-foldkit/src/route.ts | 2 +- 48 files changed, 16 insertions(+), 82 deletions(-) delete mode 100644 apps/web-foldkit/src/core/lib/date-fns-shared.ts 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/prism-modules.d.ts b/apps/web-foldkit/src/chat/prism-modules.d.ts index e60136aff..ebe107cd6 100644 --- a/apps/web-foldkit/src/chat/prism-modules.d.ts +++ b/apps/web-foldkit/src/chat/prism-modules.d.ts @@ -1,4 +1,4 @@ -// Prism language components the shared code decorator lazy-loads (no bundled types), as in apps/web. +// Prism language components the shared code decorator lazy-loads (no bundled types). declare module "prismjs/components/prism-c" declare module "prismjs/components/prism-cpp" declare module "prismjs/components/prism-csharp" 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<string, string> = { 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/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 067ae5ce2..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,6 +1,5 @@ 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", 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 6d7bda7c0..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" 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/choice-box.styles.ts b/apps/web-foldkit/src/core/components/ui/choice-box.styles.ts index 9a2376206..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: { 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 365b7005a..694985fb5 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", 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 5a34ad379..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,4 +1,3 @@ -/** 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", 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 26b2da27a..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( 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 154e909dc..efdc7ab35 100644 --- a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dialog.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 dialogBase = "peer/dialog group/dialog relative flex max-h-[inherit] flex-col overflow-hidden outline-hidden [--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]" 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 6e453c38d..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]", 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.styles.ts b/apps/web-foldkit/src/core/components/ui/field.styles.ts index aee30d591..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,6 +1,5 @@ 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-data-disabled:opacity-50 group-data-disabled:opacity-50 sm:text-sm/6", }) 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 888c3fdf9..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,4 +1,4 @@ -/** 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", 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 27c2721bb..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-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 1b4c70d16..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,6 +1,6 @@ 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]", 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 bafdf6271..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", 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 4c66a8406..5b1d6b65b 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", 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 5c5529af2..ce55e5955 100644 --- a/apps/web-foldkit/src/core/components/ui/popover.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/popover.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ 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)]", "data-entering:fade-in data-exiting:fade-out data-entering:animate-in data-exiting:animate-out", 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 60c481ae6..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", 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 a030dbf3d..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,6 +1,5 @@ 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", 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 024dfc096..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,4 +1,3 @@ -/** 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", 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 6a8c027b7..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: { 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 82a257d5c..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 = 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 ee2f99441..d8e991cac 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,6 @@ 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<Sides>) => { return sides.map((side) => ({ diff --git a/apps/web-foldkit/src/core/components/ui/split-panel/split-panel.styles.ts b/apps/web-foldkit/src/core/components/ui/split-panel/split-panel.styles.ts index b6eedbab1..b6b0bb9c7 100644 --- a/apps/web-foldkit/src/core/components/ui/split-panel/split-panel.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/split-panel/split-panel.styles.ts @@ -1,6 +1,6 @@ import { tv } from "tailwind-variants" -/** SplitPanel class composition, shared by the React and Foldkit apps. */ +/** SplitPanel class composition. */ export const rootStyles = tv({ base: "flex h-full w-full overflow-hidden", diff --git a/apps/web-foldkit/src/core/components/ui/tabs.styles.ts b/apps/web-foldkit/src/core/components/ui/tabs.styles.ts index f18f6eaa8..91176522b 100644 --- a/apps/web-foldkit/src/core/components/ui/tabs.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/tabs.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ type Orientation = "horizontal" | "vertical" | undefined export const tabsStyles = { diff --git a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts index ba441498b..047ecb61f 100644 --- a/apps/web-foldkit/src/core/components/ui/textarea.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/textarea.styles.ts @@ -1,4 +1,4 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. Composed with `cx(twJoin(textareaStyles), className)`. */ +/** Composed with `cx(twJoin(textareaStyles), className)`. */ export const textareaStyles = [ "field-sizing-content relative block min-h-16 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", diff --git a/apps/web-foldkit/src/core/components/ui/toast.styles.ts b/apps/web-foldkit/src/core/components/ui/toast.styles.ts index db7941336..8283756eb 100644 --- a/apps/web-foldkit/src/core/components/ui/toast.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/toast.styles.ts @@ -1,7 +1,5 @@ import { twJoin } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - /** sonner `toastOptions.className`, applied to every toast `<li>`. */ export const toastClassName = twJoin( "not-has-data-[slot=note]:backdrop-blur-3xl *:data-icon:mt-0.5 *:data-icon:self-start has-data-description:*:data-icon:mt-1", diff --git a/apps/web-foldkit/src/core/components/ui/toolbar.styles.ts b/apps/web-foldkit/src/core/components/ui/toolbar.styles.ts index a1522c781..ca794d112 100644 --- a/apps/web-foldkit/src/core/components/ui/toolbar.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/toolbar.styles.ts @@ -1,2 +1 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const toolbarStyles = "flex items-center gap-px" diff --git a/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts b/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts index 1c12e0b12..7baae8f98 100644 --- a/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/tooltip.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 tooltipStyles = tv({ base: [ "group max-w-sm origin-(--trigger-anchor-point) rounded-lg border border-(--tooltip-border) px-2.5 py-1.5 text-sm/6 will-change-transform [--tooltip-border:var(--color-muted-fg)]/30 dark:shadow-none *:[strong]:font-medium", diff --git a/apps/web-foldkit/src/core/lib/date-fns-shared.ts b/apps/web-foldkit/src/core/lib/date-fns-shared.ts deleted file mode 100644 index 4e6d05c22..000000000 --- a/apps/web-foldkit/src/core/lib/date-fns-shared.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** date-fns re-exported for the Foldkit app, which resolves packages through apps/web. */ -export { format, formatDistanceToNow } from "date-fns" diff --git a/apps/web-foldkit/src/data/storage.ts b/apps/web-foldkit/src/data/storage.ts index f77375fc6..0db600f57 100644 --- a/apps/web-foldkit/src/data/storage.ts +++ b/apps/web-foldkit/src/data/storage.ts @@ -4,7 +4,7 @@ import { layer as platformStorageLayer } from "~/lib/platform-storage/platform-k /** * The legacy `Atom.kvs` persistence (localStorage on the web, the Tauri store on desktop), read and - * written with the same key-value layer and schema store, so both apps share one stored value. + * written with the same key-value layer and schema store, so stored values stay compatible with the legacy app's. */ /** The stored value, or `None` when it is missing or undecodable (the atom then uses its default). */ diff --git a/apps/web-foldkit/src/editor/markdown.test.ts b/apps/web-foldkit/src/editor/markdown.test.ts index 8c67c7058..fba713860 100644 --- a/apps/web-foldkit/src/editor/markdown.test.ts +++ b/apps/web-foldkit/src/editor/markdown.test.ts @@ -11,7 +11,7 @@ import { import { schema } from "./schema" /** - * Port of `apps/web/src/components/chat/slate-editor/slate-markdown-serializer.test.ts`. + * Port of the legacy Slate editor's `slate-markdown-serializer.test.ts`. * Every case and every expected markdown string is unchanged. Only the document model * moved: `fromLegacy` builds ProseMirror nodes from the legacy Slate-shaped literals, and * `shape` projects ProseMirror nodes back to that shape so structural assertions read the diff --git a/apps/web-foldkit/src/page/chat/pinned.ts b/apps/web-foldkit/src/page/chat/pinned.ts index 429ffb6e2..fc1e9207a 100644 --- a/apps/web-foldkit/src/page/chat/pinned.ts +++ b/apps/web-foldkit/src/page/chat/pinned.ts @@ -1,5 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { format } from "~/lib/date-fns-shared" +import { format } from "date-fns" import { IconClose, IconPin } from "../../icons" import { avatar } from "../../ui/avatar" import * as Popover from "../../ui/popover" diff --git a/apps/web-foldkit/src/page/chat/thread-panel.ts b/apps/web-foldkit/src/page/chat/thread-panel.ts index 8b8ba9c39..4f9c6162c 100644 --- a/apps/web-foldkit/src/page/chat/thread-panel.ts +++ b/apps/web-foldkit/src/page/chat/thread-panel.ts @@ -1,6 +1,6 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { handleStyles, indicatorStyles, panelStyles } from "~/components/ui/split-panel/split-panel.styles" -import { format } from "~/lib/date-fns-shared" +import { format } from "date-fns" import { messageRowView, type RowContext } from "../../chat/message/row" import { markdownView } from "../../chat/markdown/markdown-view" import { IconClose, IconEdit, IconPenSparkle } from "../../icons" diff --git a/apps/web-foldkit/src/page/my-settings/shared.ts b/apps/web-foldkit/src/page/my-settings/shared.ts index 946bd35cc..801cbef67 100644 --- a/apps/web-foldkit/src/page/my-settings/shared.ts +++ b/apps/web-foldkit/src/page/my-settings/shared.ts @@ -1,5 +1,3 @@ -import { Option } from "effect" -import { Subscription, Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as Interaction from "../../ui/aria/interaction" import { @@ -42,16 +40,4 @@ export const divider = <Message>(h: HtmlBuilder<Message>): Html => /** The interaction Submodel wiring (hover, press, focus-visible) a page embeds once. */ export const embedInteraction = <Model extends { readonly interaction: Interaction.Model }, Message>( toParentMessage: (message: Interaction.Message) => Message, -) => ({ - fold: Update.foldChild({ - update: Interaction.update, - read: (model: Model) => Option.some(model.interaction), - write: (model: Model, interaction: Interaction.Model): Model => ({ ...model, interaction }), - toParentMessage, - }), - subscriptions: Subscription.lift(Interaction.subscriptions)<PageSubscriptionInput<Model>, Message>({ - read: (input) => Option.some(input.model.interaction), - toParentMessage, - }), - wiring: (model: Model): Interaction.Wiring<Message> => ({ model: model.interaction, toParentMessage }), -}) +) => Interaction.embed<Model, Message, PageSubscriptionInput<Model>>(toParentMessage, (input) => input.model) diff --git a/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts b/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts index 16939d813..1185cf62f 100644 --- a/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts +++ b/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts @@ -1,21 +1,7 @@ -import { Option } from "effect" -import { Subscription, Update } from "foldkit" import * as Interaction from "../../../../ui/aria/interaction" import type { PageSubscriptionInput } from "../../../contract" /** A page's interaction Submodel (React Aria hover, press, focus state): fold, Subscriptions, wiring. */ export const embedInteraction = <Model extends { readonly interaction: Interaction.Model }, Message>( toParentMessage: (message: Interaction.Message) => Message, -) => ({ - fold: Update.foldChild({ - update: Interaction.update, - read: (model: Model) => Option.some(model.interaction), - write: (model: Model, interaction: Interaction.Model): Model => ({ ...model, interaction }), - toParentMessage, - }), - subscriptions: Subscription.lift(Interaction.subscriptions)<PageSubscriptionInput<Model>, Message>({ - read: (input) => Option.some(input.model.interaction), - toParentMessage, - }), - wiring: (model: Model): Interaction.Wiring<Message> => ({ model: model.interaction, toParentMessage }), -}) +) => Interaction.embed<Model, Message, PageSubscriptionInput<Model>>(toParentMessage, (input) => input.model) diff --git a/apps/web-foldkit/src/route.ts b/apps/web-foldkit/src/route.ts index 01577dec6..4dc541428 100644 --- a/apps/web-foldkit/src/route.ts +++ b/apps/web-foldkit/src/route.ts @@ -3,7 +3,7 @@ import { Option, Schema, pipe } from "effect" import { Route } from "foldkit" import { defineRouteUnion, literal, query, restString, schemaSegment, slash, string } from "foldkit/route" -/** Flat route union mirroring the 39 legacy TanStack routes (`apps/web/src/routes`), plus NotFound. */ +/** Flat route union mirroring the 39 legacy TanStack routes, plus NotFound. */ export const AppRoute = defineRouteUnion({ Root: {}, SignIn: { splat: Schema.String, redirectUrl: Schema.Option(Schema.String) }, From 3e4b1bd5ec829810e6747ea24571de120809cf75 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:18:03 +0200 Subject: [PATCH 23/52] refactor(backend): typed failures instead of defects, catch-alls and sync decode throws - bot.create/install/uninstall/installById: Effect.die("User must belong to an organization") is now ActiveOrganizationRequiredError (400), added to the unions. - generateTransactionId keeps the typed DatabaseError/SchemaError instead of dying with a stringified cause; handlers remap it with withRemapDbErrors. - Hand-written DatabaseError/SchemaError -> InternalServerError catches in the chat-sync RPC and HTTP handlers, integrations, organization members and the organization slug handler now use withRemapDbErrors. - Craft rate limits, 5xx and transport failures are IntegrationUnavailableError (503) instead of InternalServerError. - parseOAuthStateParam and the OAuth session cookie decode with decodeUnknownOption instead of try/catch around decodeUnknownSync and JSON.parse; the same for the outbound-identity settings in the chat-sync core worker. - Discord webhook fallbacks catch DiscordApiError only (a DB failure no longer silently falls back to the bot API); integration-bot best-effort adds catch DatabaseError/SchemaError and log the Cause. - connect-shares helpers drop `any`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/backend/src/lib/create-transactionId.ts | 13 +- apps/backend/src/routes/chat-sync.http.ts | 62 +----- .../src/routes/integrations.http.test.ts | 40 ++-- apps/backend/src/routes/integrations.http.ts | 187 ++++++------------ apps/backend/src/rpc/handlers/bots.ts | 18 +- apps/backend/src/rpc/handlers/channels.ts | 2 +- apps/backend/src/rpc/handlers/chat-sync.ts | 95 +-------- .../src/rpc/handlers/connect-shares.ts | 28 +-- .../backend/src/rpc/handlers/notifications.ts | 4 +- .../src/rpc/handlers/organization-members.ts | 14 +- .../backend/src/rpc/handlers/organizations.ts | 90 +++------ .../chat-sync/chat-sync-core-worker.ts | 95 ++++----- .../chat-sync/discord-sync-worker.test.ts | 10 +- apps/backend/src/services/hazel-auth.test.ts | 1 - apps/backend/src/services/invitations.ts | 2 +- packages/domain/src/errors.ts | 14 ++ packages/domain/src/http/integrations.ts | 17 +- packages/domain/src/rpc/bots.ts | 12 +- 18 files changed, 243 insertions(+), 461 deletions(-) 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/routes/chat-sync.http.ts b/apps/backend/src/routes/chat-sync.http.ts index 126c39f70..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, - cause: error, - }) - const normalizeChannelLinkSettings = ( settings: Record<string, unknown> | null | undefined, ): Record<string, unknown> => ({ @@ -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/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 d92f65c1d..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<string, unknown> => { @@ -220,25 +209,15 @@ const getOAuthStateCandidates = (rawState: string): ReadonlyArray<string> => { 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<typeof OAuthState.Type> => + Option.firstSomeOf(getOAuthStateCandidates(rawState).map((candidate) => decodeOAuthState(candidate))) const makeOAuthSessionCookie = ( name: string, @@ -483,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 @@ -501,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 @@ -575,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", { @@ -926,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), + }), ) } @@ -956,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 @@ -1049,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({ @@ -1213,14 +1191,7 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" ) .handle("oauthCallback", ({ params, query }) => handleOAuthCallback(params, query).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error during OAuth callback", - cause: error, - }), - ), - ), + withRemapDbErrors("IntegrationConnection", "create"), Effect.catchTag("ConfigError", (err) => Effect.fail(new InternalServerError({ message: "Missing configuration", cause: err })), ), @@ -1228,56 +1199,20 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" ) .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", - cause: error, - }), - ), - SchemaError: (error: { readonly _tag: "SchemaError"; readonly message?: string }) => - Effect.fail( - new InternalServerError({ - message: "Failed to parse API response", - cause: error, - }), - ), - IntegrationEncryptionError: (error: { - readonly _tag: "IntegrationEncryptionError" - readonly message?: string - }) => - Effect.fail( - new InternalServerError({ - message: "Failed to encrypt token", - cause: 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", - cause: 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", - cause: error, - }), - ), - ), - ), + handleDisconnect(params, query).pipe(withRemapDbErrors("IntegrationConnection", "delete")), ), ).pipe(Layer.provide(CraftApiClient.layer), Layer.provide(IntegrationBotService.layer)) 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 541ebcd33..a96b2a293 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -14,7 +14,7 @@ import { 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" diff --git a/apps/backend/src/rpc/handlers/chat-sync.ts b/apps/backend/src/rpc/handlers/chat-sync.ts index d97789178..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", - cause: error, - }), - ), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while creating sync connection", - cause: 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", - cause: 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", - cause: 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", - cause: error, - }), - ), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while creating channel link", - cause: 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", - cause: 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", - cause: 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", - cause: 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 de2d17d80..2d444ba3e 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.ts @@ -38,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, @@ -134,32 +134,32 @@ export const assertGuestMemberAddsAllowed = ( ) : Effect.void -export const remapGuestMountInsertConflict = ({ +export const remapGuestMountInsertConflict = <E, R>({ error, guestOrganizationId, findExistingMount, }: { - error: any + error: Database.DatabaseError guestOrganizationId: OrganizationId - findExistingMount: () => Effect.Effect<Option.Option<{ channelId: ChannelId }>, any, any> -}) => { + findExistingMount: () => Effect.Effect<Option.Option<{ channelId: ChannelId }>, E, R> +}): Effect.Effect<never, Database.DatabaseError | ConnectChannelAlreadySharedError | E, R> => { 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", }), - ), - }), + }), + ), ), ) } 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 a16c7f07e..7b97ca6cf 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -10,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 = <R, E extends { _tag: string }, A>( - entityType: string, - action: "update" | "create", -) => { - return ( - effect: Effect.Effect<R, E, A>, - ): Effect.Effect< - R, - | Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> - | InternalServerError - | OrganizationSlugAlreadyExistsError, - A - > => { - return effect.pipe( - Effect.catchIf( - (e): e is Extract<E, { _tag: "DatabaseError" }> => Predicate.isTagged(e, "DatabaseError"), - (err): Effect.Effect<never, InternalServerError | OrganizationSlugAlreadyExistsError> => { - 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: err, - }), - ) - }, - ), - Effect.catchIf( - (e): e is Extract<E, { _tag: "SchemaError" }> => 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: err, - }), - ), - ), - ) as Effect.Effect< - R, - | Exclude<E, { _tag: "DatabaseError" | "SchemaError" }> - | InternalServerError - | OrganizationSlugAlreadyExistsError, - A - > - } -} +const handleOrganizationDbErrors = + (entityType: string, action: "update" | "create") => + <A, E, R>(effect: Effect.Effect<A, E, R>) => + effect.pipe( + Effect.mapError((error) => (isSlugTaken(error) ? slugTaken(error) : error)), + withRemapDbErrors(entityType, action), + ) /** * Organization RPC Handlers 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 91d78e725..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 @@ -379,6 +379,12 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk ) const decodeProvider = Schema.decodeUnknownSync(IntegrationConnection.IntegrationProvider) + const decodeOutboundIdentitySettings = Schema.decodeUnknownOption( + ChatSyncChannelLink.OutboundIdentitySettings, + ) + const decodeDiscordWebhookConfig = Schema.decodeUnknownOption( + ChatSyncChannelLink.DiscordWebhookOutboundIdentityConfig, + ) type WebhookPermissionStatus = "unknown" | "allowed" | "denied" @@ -423,11 +429,6 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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" @@ -455,11 +456,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk if (raw === undefined) { return defaultOutboundIdentitySettings() } - try { - return Schema.decodeUnknownSync(ChatSyncChannelLink.OutboundIdentitySettings)(raw) - } catch { - return defaultOutboundIdentitySettings() - } + return Option.getOrElse(decodeOutboundIdentitySettings(raw), defaultOutboundIdentitySettings) } const getDiscordWebhookConfig = ( @@ -478,15 +475,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, unk return Option.none() } - try { - return Option.some( - Schema.decodeUnknownSync(ChatSyncChannelLink.DiscordWebhookOutboundIdentityConfig)( - providerConfig, - ), - ) - } catch { - return Option.none() - } + return decodeDiscordWebhookConfig(providerConfig) } const shouldIgnoreWebhookOrigin = ( @@ -604,9 +593,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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, @@ -623,23 +614,21 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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() }), ), @@ -710,13 +699,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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<ExternalMessageId>())), ), ) }, @@ -755,16 +742,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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)), ), ) }, @@ -801,16 +783,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect<Record<string, Function>, 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)), ), ) }, 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/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts index 077b624a4..a0ad344ed 100644 --- a/apps/backend/src/services/hazel-auth.test.ts +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -25,7 +25,6 @@ import type { SuccessValue } from "effect/rpc/RpcMiddleware" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { AuthMiddlewareLive } from "../rpc/middleware/auth" import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" -import { serviceShape } from "../test/effect-helpers" import { AuthorizationLive } from "./auth" import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./hazel-auth" import { HazelSession } from "./hazel-session" diff --git a/apps/backend/src/services/invitations.ts b/apps/backend/src/services/invitations.ts index c90762979..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, diff --git a/packages/domain/src/errors.ts b/packages/domain/src/errors.ts index c0fd77b92..c185b8d0a 100644 --- a/packages/domain/src/errors.ts +++ b/packages/domain/src/errors.ts @@ -124,6 +124,20 @@ export class InvalidChannelRequestError extends Schema.TaggedError<InvalidChanne { httpApiStatus: 400 }, ) {} +/** + * The operation acts on the caller's active organization, and the session has none + * (`CurrentUser.organizationId` is unset). + */ +export class ActiveOrganizationRequiredError extends Schema.TaggedError<ActiveOrganizationRequiredError>( + "ActiveOrganizationRequiredError", +)( + "ActiveOrganizationRequiredError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + export class DmChannelAlreadyExistsError extends Schema.TaggedError<DmChannelAlreadyExistsError>( "DmChannelAlreadyExistsError", )( diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 01cd5c84c..3c08dbf8d 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -52,6 +52,15 @@ export class InvalidApiKeyError extends Schema.TaggedError<InvalidApiKeyError>() message: Schema.String, }) {} +/** The provider is rate limiting or failing right now; the same request may succeed later. */ +export class IntegrationUnavailableError extends Schema.TaggedError<IntegrationUnavailableError>()( + "IntegrationUnavailableError", + { + message: Schema.String, + }, + { httpApiStatus: 503 }, +) {} + export class ConnectApiKeyRequest extends Schema.Class<ConnectApiKeyRequest>("ConnectApiKeyRequest")({ token: Schema.String, baseUrl: Schema.String, @@ -149,7 +158,13 @@ export class IntegrationGroup extends HttpApiGroup.make("integrations") }, payload: ConnectApiKeyRequest, success: ConnectApiKeyResponse, - error: [InvalidApiKeyError, UnsupportedProviderError, UnauthorizedError, InternalServerError], + error: [ + InvalidApiKeyError, + IntegrationUnavailableError, + UnsupportedProviderError, + UnauthorizedError, + InternalServerError, + ], }) .middleware(CurrentUser.Authorization) .annotateMerge( diff --git a/packages/domain/src/rpc/bots.ts b/packages/domain/src/rpc/bots.ts index 63350010a..08bb5c4e6 100644 --- a/packages/domain/src/rpc/bots.ts +++ b/packages/domain/src/rpc/bots.ts @@ -1,7 +1,7 @@ import { Rpc, RpcGroup } from "effect/rpc" import { BotId } from "@hazel/schema" import { Schema } from "effect" -import { InternalServerError, UnauthorizedError } from "../errors" +import { ActiveOrganizationRequiredError, InternalServerError, UnauthorizedError } from "../errors" import { Bot, BotCommand } from "../models" import { RateLimitExceededError } from "../rate-limit-errors" import { TransactionId } from "@hazel/schema" @@ -120,7 +120,12 @@ export class BotRpcs extends RpcGroup.make( isPublic: Schema.optional(Schema.Boolean), }), success: BotCreatedResponse, - error: Schema.Union([UnauthorizedError, InternalServerError, RateLimitExceededError]), + error: Schema.Union([ + ActiveOrganizationRequiredError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), }) .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), @@ -302,6 +307,7 @@ export class BotRpcs extends RpcGroup.make( payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), error: Schema.Union([ + ActiveOrganizationRequiredError, BotNotFoundError, BotAlreadyInstalledError, UnauthorizedError, @@ -326,6 +332,7 @@ export class BotRpcs extends RpcGroup.make( payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), error: Schema.Union([ + ActiveOrganizationRequiredError, BotNotFoundError, UnauthorizedError, InternalServerError, @@ -351,6 +358,7 @@ export class BotRpcs extends RpcGroup.make( payload: Schema.Struct({ botId: BotId }), success: Schema.Struct({ transactionId: TransactionId }), error: Schema.Union([ + ActiveOrganizationRequiredError, BotNotFoundError, BotAlreadyInstalledError, UnauthorizedError, From ef4b982e29a30b11d891aa9411222642696a3387 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:18:29 +0200 Subject: [PATCH 24/52] docs: point CLAUDE.md at apps/backend and Hazel auth The backend lives in apps/backend (not backendv2) and signs users in with Hazel auth; Clerk only verifies Maple's OAuth tokens. Adds the backend test commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 12 +++++++----- packages/domain/src/scopes/required-scopes.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) 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/packages/domain/src/scopes/required-scopes.ts b/packages/domain/src/scopes/required-scopes.ts index dabdd69d6..2ea5b6b10 100644 --- a/packages/domain/src/scopes/required-scopes.ts +++ b/packages/domain/src/scopes/required-scopes.ts @@ -2,7 +2,7 @@ import { Context } from "effect" import type { ApiScope } from "./api-scope" /** - * Context.Tag for declaring required API scopes on RPC and HTTP endpoints. + * Annotation key for declaring required API scopes on RPC and HTTP endpoints. * * Usage: * ```typescript From 3c883b1c170d38a09596b62af0991b1e33dec19d Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:19:54 +0200 Subject: [PATCH 25/52] refactor(web): appearance previews are static SVG files The onboarding theme step and the appearance settings each rebuilt the same three illustrations (about 3,600 lines of h.* calls) on every render. They are now public/images/appearance/{system,light,dark}.svg behind <img>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../public/images/appearance/dark.svg | 1 + .../public/images/appearance/light.svg | 1 + .../public/images/appearance/system.svg | 1 + .../my-settings/appearance/illustrations.ts | 1826 ----------------- .../src/page/my-settings/appearance/view.ts | 23 +- .../src/page/onboarding/steps/theme.ts | 18 +- .../web-foldkit/src/ui/appearance-previews.ts | 1815 ---------------- 7 files changed, 26 insertions(+), 3659 deletions(-) create mode 100644 apps/web-foldkit/public/images/appearance/dark.svg create mode 100644 apps/web-foldkit/public/images/appearance/light.svg create mode 100644 apps/web-foldkit/public/images/appearance/system.svg delete mode 100644 apps/web-foldkit/src/page/my-settings/appearance/illustrations.ts delete mode 100644 apps/web-foldkit/src/ui/appearance-previews.ts 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 @@ +<svg width="200" height="133" viewBox="0 0 200 133" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_8021_27290)"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="#F5F5F5"/><g clip-path="url(#clip1_8021_27290)"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="#22262F"/><rect width="117.5" height="1.25001" transform="translate(13.125 118) rotate(-90)" fill="#373A41"/><rect width="200" height="1.25" transform="translate(0 13.625)" fill="#373A41"/><rect width="117.5" height="1.25001" transform="translate(185.625 118) rotate(-90)" fill="#373A41"/><g clip-path="url(#clip2_8021_27290)"><rect x="12.5" y="13" width="175" height="130" rx="5" fill="#0C0E12"/><mask id="path-6-inside-1_8021_27290" fill="white"><path d="M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z"/></mask><path d="M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z" fill="#13161B"/><path d="M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z" fill="#373A41" mask="url(#path-6-inside-1_8021_27290)"/><g filter="url(#filter0_i_8021_27290)"><circle cx="18.75" cy="18.625" r="1.875" fill="#EE6A5F"/></g><circle cx="18.75" cy="18.625" r="1.79688" stroke="#CE5347" stroke-width="0.15625"/><g filter="url(#filter1_i_8021_27290)"><circle cx="25" cy="18.625" r="1.875" fill="#F5BD4F"/></g><circle cx="25" cy="18.625" r="1.79688" stroke="#D6A243" stroke-width="0.15625"/><g filter="url(#filter2_i_8021_27290)"><circle cx="31.25" cy="18.625" r="1.875" fill="#61C454"/></g><circle cx="31.25" cy="18.625" r="1.79688" stroke="#58A942" stroke-width="0.15625"/><rect x="63.75" y="50.8125" width="117.5" height="45" rx="3.75" fill="#13161B"/><mask id="mask0_8021_27290" style="mask-type: alpha;" maskUnits="userSpaceOnUse" x="70" y="58" width="105" height="33"><rect width="105" height="32.5" transform="translate(70 58.3125)" fill="url(#paint0_linear_8021_27290)"/></mask><g mask="url(#mask0_8021_27290)"><path opacity="0.1" d="M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z" fill="#9E77ED"/></g><path d="M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175" stroke="#9E77ED" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/><path d="M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z" fill="#13161B"/><rect x="63.75" y="42.0625" width="65" height="3.75" rx="1.25" fill="#22262F"/><path d="M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z" fill="#F7F7F7"/><rect x="138.125" y="29.875" width="20" height="6.875" rx="1.875" fill="#22262F"/><rect x="161.25" y="29.875" width="20" height="6.875" rx="1.875" fill="#373A41"/><mask id="path-22-inside-2_8021_27290" fill="white"><path d="M12.5 23.625H57.5V143H12.5V23.625Z"/></mask><path d="M57.1875 23.625V143H57.8125V23.625H57.1875Z" fill="#22262F" mask="url(#path-22-inside-2_8021_27290)"/><rect x="25.625" y="30.5" width="13.75" height="3.75" rx="1.25" fill="#373A41"/><rect x="18.75" y="29.875" width="5" height="5" rx="2.5" fill="#373A41"/><rect x="18.75" y="42.375" width="27.5" height="5" rx="1.25" fill="#373A41"/><rect x="18.75" y="51.125" width="27.5" height="5" rx="1.25" fill="#22262F"/><rect x="18.75" y="59.875" width="27.5" height="5" rx="1.25" fill="#22262F"/><rect x="18.75" y="68.625" width="27.5" height="5" rx="1.25" fill="#22262F"/><rect x="18.75" y="77.375" width="27.5" height="5" rx="1.25" fill="#22262F"/><rect x="18.75" y="86.125" width="27.5" height="5" rx="1.25" fill="#22262F"/></g><rect x="12.8125" y="13.3125" width="174.375" height="129.375" rx="4.6875" stroke="#373A41" stroke-width="0.625"/></g><path d="M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z" stroke="#373A41"/></g><defs><filter id="filter0_i_8021_27290" x="16.875" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27290"/></filter><filter id="filter1_i_8021_27290" x="23.125" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27290"/></filter><filter id="filter2_i_8021_27290" x="29.375" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27290"/></filter><linearGradient id="paint0_linear_8021_27290" x1="52.5" y1="0" x2="52.5" y2="32.5" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient><clipPath id="clip0_8021_27290"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="white"/></clipPath><clipPath id="clip1_8021_27290"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="white"/></clipPath><clipPath id="clip2_8021_27290"><rect x="12.5" y="13" width="175" height="130" rx="5" fill="white"/></clipPath></defs></svg> 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 @@ +<svg width="200" height="133" viewBox="0 0 200 133" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_8021_27248)"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="#F5F5F5"/><g clip-path="url(#clip1_8021_27248)"><rect width="117.5" height="1.25001" transform="translate(13.125 118) rotate(-90)" fill="#E9EAEB"/><rect width="200" height="1.25" transform="translate(0 13.625)" fill="#E9EAEB"/><rect width="117.5" height="1.25001" transform="translate(185.625 118) rotate(-90)" fill="#E9EAEB"/><g clip-path="url(#clip2_8021_27248)"><rect x="12.5" y="13" width="175" height="130" rx="5" fill="white"/><mask id="path-6-inside-1_8021_27248" fill="white"><path d="M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z"/></mask><path d="M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z" fill="#FAFAFA"/><path d="M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z" fill="#E9EAEB" mask="url(#path-6-inside-1_8021_27248)"/><g filter="url(#filter0_i_8021_27248)"><circle cx="18.75" cy="18.625" r="1.875" fill="#EE6A5F"/></g><circle cx="18.75" cy="18.625" r="1.79688" stroke="#CE5347" stroke-width="0.15625"/><g filter="url(#filter1_i_8021_27248)"><circle cx="25" cy="18.625" r="1.875" fill="#F5BD4F"/></g><circle cx="25" cy="18.625" r="1.79688" stroke="#D6A243" stroke-width="0.15625"/><g filter="url(#filter2_i_8021_27248)"><circle cx="31.25" cy="18.625" r="1.875" fill="#61C454"/></g><circle cx="31.25" cy="18.625" r="1.79688" stroke="#58A942" stroke-width="0.15625"/><rect x="63.75" y="50.8125" width="117.5" height="45" rx="3.75" fill="#FAFAFA"/><mask id="mask0_8021_27248" style="mask-type: alpha;" maskUnits="userSpaceOnUse" x="70" y="58" width="105" height="33"><rect width="105" height="32.5" transform="translate(70 58.3125)" fill="url(#paint0_linear_8021_27248)"/></mask><g mask="url(#mask0_8021_27248)"><path opacity="0.1" d="M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z" fill="#7F56D9"/></g><path d="M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175" stroke="#7F56D9" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/><path d="M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z" fill="#FAFAFA"/><rect x="63.75" y="42.0625" width="65" height="3.75" rx="1.25" fill="#F5F5F5"/><path d="M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z" fill="#181D27"/><rect x="138.125" y="29.875" width="20" height="6.875" rx="1.875" fill="#F5F5F5"/><rect x="161.25" y="29.875" width="20" height="6.875" rx="1.875" fill="#7F56D9"/><mask id="path-22-inside-2_8021_27248" fill="white"><path d="M12.5 23.625H57.5V143H12.5V23.625Z"/></mask><path d="M57.1875 23.625V143H57.8125V23.625H57.1875Z" fill="#E9EAEB" mask="url(#path-22-inside-2_8021_27248)"/><rect x="25.625" y="30.5" width="13.75" height="3.75" rx="1.25" fill="#7F56D9"/><rect x="18.75" y="29.875" width="5" height="5" rx="2.5" fill="#7F56D9"/><rect x="18.75" y="42.375" width="27.5" height="5" rx="1.25" fill="#7F56D9"/><rect x="18.75" y="51.125" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="59.875" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="68.625" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="77.375" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="86.125" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/></g><rect x="12.8125" y="13.3125" width="174.375" height="129.375" rx="4.6875" stroke="#D5D7DA" stroke-width="0.625"/></g><path d="M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z" stroke="#D5D7DA"/></g><defs><filter id="filter0_i_8021_27248" x="16.875" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27248"/></filter><filter id="filter1_i_8021_27248" x="23.125" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27248"/></filter><filter id="filter2_i_8021_27248" x="29.375" y="16.75" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_8021_27248"/></filter><linearGradient id="paint0_linear_8021_27248" x1="52.5" y1="0" x2="52.5" y2="32.5" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient><clipPath id="clip0_8021_27248"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="white"/></clipPath><clipPath id="clip1_8021_27248"><path d="M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z" fill="white"/></clipPath><clipPath id="clip2_8021_27248"><rect x="12.5" y="13" width="175" height="130" rx="5" fill="white"/></clipPath></defs></svg> 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 @@ +<svg width="200" height="132" viewBox="0 0 200 132" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_4931_10045)"><path d="M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z" fill="#F5F5F5"/><g clip-path="url(#clip1_4931_10045)"><path d="M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z" fill="#F5F5F5"/><rect width="117.5" height="1.25001" transform="translate(13.125 117.5) rotate(-90)" fill="#E9EAEB"/><rect width="200" height="1.25" transform="translate(0 13.125)" fill="#E9EAEB"/><rect width="117.5" height="1.25001" transform="translate(185.625 117.5) rotate(-90)" fill="#E9EAEB"/><g clip-path="url(#clip2_4931_10045)"><rect x="12.5" y="12.5" width="175" height="130" rx="5" fill="white"/><mask id="path-6-inside-1_4931_10045" fill="white"><path d="M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z"/></mask><path d="M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z" fill="#FAFAFA"/><path d="M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z" fill="#E9EAEB" mask="url(#path-6-inside-1_4931_10045)"/><g filter="url(#filter0_i_4931_10045)"><circle cx="18.75" cy="18.125" r="1.875" fill="#EE6A5F"/></g><circle cx="18.75" cy="18.125" r="1.79688" stroke="#CE5347" stroke-width="0.15625"/><g filter="url(#filter1_i_4931_10045)"><circle cx="25" cy="18.125" r="1.875" fill="#F5BD4F"/></g><circle cx="25" cy="18.125" r="1.79688" stroke="#D6A243" stroke-width="0.15625"/><g filter="url(#filter2_i_4931_10045)"><circle cx="31.25" cy="18.125" r="1.875" fill="#61C454"/></g><circle cx="31.25" cy="18.125" r="1.79688" stroke="#58A942" stroke-width="0.15625"/><rect x="63.75" y="50.3125" width="117.5" height="45" rx="3.75" fill="#FAFAFA"/><mask id="mask0_4931_10045" style="mask-type: alpha;" maskUnits="userSpaceOnUse" x="70" y="57" width="105" height="34"><rect width="105" height="32.5" transform="translate(70 57.8125)" fill="url(#paint0_linear_4931_10045)"/></mask><g mask="url(#mask0_4931_10045)"><path opacity="0.1" d="M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z" fill="#7F56D9"/></g><path d="M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175" stroke="#7F56D9" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/><path d="M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z" fill="#FAFAFA"/><rect x="63.75" y="41.5625" width="65" height="3.75" rx="1.25" fill="#F5F5F5"/><path d="M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z" fill="#181D27"/><rect x="138.125" y="29.375" width="20" height="6.875" rx="1.875" fill="#F5F5F5"/><rect x="161.25" y="29.375" width="20" height="6.875" rx="1.875" fill="#7F56D9"/><mask id="path-22-inside-2_4931_10045" fill="white"><path d="M12.5 23.125H57.5V142.5H12.5V23.125Z"/></mask><path d="M57.1875 23.125V142.5H57.8125V23.125H57.1875Z" fill="#E9EAEB" mask="url(#path-22-inside-2_4931_10045)"/><rect x="25.625" y="30" width="13.75" height="3.75" rx="1.25" fill="#7F56D9"/><rect x="18.75" y="29.375" width="5" height="5" rx="2.5" fill="#7F56D9"/><rect x="18.75" y="41.875" width="27.5" height="5" rx="1.25" fill="#7F56D9"/><rect x="18.75" y="50.625" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="59.375" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="68.125" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="76.875" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/><rect x="18.75" y="85.625" width="27.5" height="5" rx="1.25" fill="#F5F5F5"/></g><rect x="12.8125" y="12.8125" width="174.375" height="129.375" rx="4.6875" stroke="#D5D7DA" stroke-width="0.625"/></g><path d="M10 0.5H190C195.247 0.5 199.5 4.7533 199.5 10V122C199.5 127.247 195.247 131.5 190 131.5H10C4.7533 131.5 0.5 127.247 0.5 122V10C0.5 4.75329 4.75329 0.5 10 0.5Z" stroke="#D5D7DA"/><g clip-path="url(#clip3_4931_10045)"><mask id="path-32-inside-3_4931_10045" fill="white"><path d="M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z"/></mask><path d="M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z" fill="#22262F"/><rect width="200" height="1.25" transform="translate(0 13.125)" fill="#373A41"/><rect width="117.5" height="1.25001" transform="translate(185.625 117.5) rotate(-90)" fill="#373A41"/><g clip-path="url(#clip4_4931_10045)"><rect x="12.5" y="12.5" width="175" height="130" rx="5" fill="#0C0E12"/><mask id="path-36-inside-4_4931_10045" fill="white"><path d="M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z"/></mask><path d="M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z" fill="#13161B"/><path d="M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z" fill="#373A41" mask="url(#path-36-inside-4_4931_10045)"/><rect x="63.75" y="50.3125" width="117.5" height="45" rx="3.75" fill="#13161B"/><mask id="mask1_4931_10045" style="mask-type: alpha;" maskUnits="userSpaceOnUse" x="70" y="57" width="105" height="34"><rect width="105" height="32.5" transform="translate(70 57.8125)" fill="url(#paint1_linear_4931_10045)"/></mask><g mask="url(#mask1_4931_10045)"><path opacity="0.1" d="M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z" fill="#9E77ED"/></g><path d="M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175" stroke="#9E77ED" stroke-width="0.625" stroke-linecap="round" stroke-linejoin="round"/><path d="M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z" fill="#13161B"/><rect x="63.75" y="41.5625" width="65" height="3.75" rx="1.25" fill="#22262F"/><path d="M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z" fill="#F7F7F7"/><rect x="138.125" y="29.375" width="20" height="6.875" rx="1.875" fill="#22262F"/><rect x="161.25" y="29.375" width="20" height="6.875" rx="1.875" fill="#7F56D9"/></g><rect x="12.8125" y="12.8125" width="174.375" height="129.375" rx="4.6875" stroke="#373A41" stroke-width="0.625"/></g><path d="M100 -1H190C196.075 -1 201 3.92487 201 10H199C199 5.02944 194.971 1 190 1H100V-1ZM201 122C201 128.075 196.075 133 190 133H100V131H190C194.971 131 199 126.971 199 122H201ZM100 132V0V132ZM190 -1C196.075 -1 201 3.92487 201 10V122C201 128.075 196.075 133 190 133V131C194.971 131 199 126.971 199 122V10C199 5.02944 194.971 1 190 1V-1Z" fill="#373A41" mask="url(#path-32-inside-3_4931_10045)"/></g><defs><filter id="filter0_i_4931_10045" x="16.875" y="16.25" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_4931_10045"/></filter><filter id="filter1_i_4931_10045" x="23.125" y="16.25" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_4931_10045"/></filter><filter id="filter2_i_4931_10045" x="29.375" y="16.25" width="3.75" height="3.75" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="0.9375"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0"/><feBlend mode="normal" in2="shape" result="effect1_innerShadow_4931_10045"/></filter><linearGradient id="paint0_linear_4931_10045" x1="52.5" y1="0" x2="52.5" y2="32.5" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient><linearGradient id="paint1_linear_4931_10045" x1="52.5" y1="0" x2="52.5" y2="32.5" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient><clipPath id="clip0_4931_10045"><path d="M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z" fill="white"/></clipPath><clipPath id="clip1_4931_10045"><path d="M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z" fill="white"/></clipPath><clipPath id="clip2_4931_10045"><rect x="12.5" y="12.5" width="175" height="130" rx="5" fill="white"/></clipPath><clipPath id="clip3_4931_10045"><path d="M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z" fill="white"/></clipPath><clipPath id="clip4_4931_10045"><rect x="12.5" y="12.5" width="175" height="130" rx="5" fill="white"/></clipPath></defs></svg> diff --git a/apps/web-foldkit/src/page/my-settings/appearance/illustrations.ts b/apps/web-foldkit/src/page/my-settings/appearance/illustrations.ts deleted file mode 100644 index c2971498d..000000000 --- a/apps/web-foldkit/src/page/my-settings/appearance/illustrations.ts +++ /dev/null @@ -1,1826 +0,0 @@ -// Originally generated from the React app's appearance illustrations. Edit by hand. -import type { Html, HtmlBuilder } from "foldkit/html" - -const illustration = <Message>( - h: HtmlBuilder<Message>, - root: Readonly<Record<string, string>>, - className: string, - children: Html[], -): Html => - h.svg( - [...Object.entries(root).map(([key, value]) => h.Attribute(key, value)), h.Class(className)], - children, - ) - -export const System = <Message>(h: HtmlBuilder<Message>, className: string): Html => - illustration( - h, - { - width: "200", - height: "132", - viewBox: "0 0 200 132", - fill: "none", - xmlns: "http://www.w3.org/2000/svg", - }, - className, - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_4931_10045)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_4931_10045)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 117.5) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.125)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 117.5) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_4931_10045)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z", - ), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-6-inside-1_4931_10045)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.3125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#FAFAFA"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_4931_10045"), - h.Style({ "mask-type": "alpha" }), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "57"), - h.Attribute("width", "105"), - h.Attribute("height", "34"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 57.8125)"), - h.Attribute("fill", "url(#paint0_linear_4931_10045)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_4931_10045)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z", - ), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175", - ), - h.Attribute("stroke", "#7F56D9"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "41.5625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z", - ), - h.Attribute("fill", "#181D27"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_4931_10045"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.125H57.5V142.5H12.5V23.125Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.125V142.5H57.8125V23.125H57.1875Z"), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-22-inside-2_4931_10045)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.375"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "41.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "50.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "76.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "85.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "12.8125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#D5D7DA"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M10 0.5H190C195.247 0.5 199.5 4.7533 199.5 10V122C199.5 127.247 195.247 131.5 190 131.5H10C4.7533 131.5 0.5 127.247 0.5 122V10C0.5 4.75329 4.75329 0.5 10 0.5Z", - ), - h.Attribute("stroke", "#D5D7DA"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip3_4931_10045)")], - [ - h.mask( - [ - h.Attribute("id", "path-32-inside-3_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.125)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 117.5) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip4_4931_10045)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "#0C0E12"), - ]), - h.mask( - [ - h.Attribute("id", "path-36-inside-4_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-36-inside-4_4931_10045)"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.3125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#13161B"), - ]), - h.mask( - [ - h.Attribute("id", "mask1_4931_10045"), - h.Style({ "mask-type": "alpha" }), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "57"), - h.Attribute("width", "105"), - h.Attribute("height", "34"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 57.8125)"), - h.Attribute("fill", "url(#paint1_linear_4931_10045)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask1_4931_10045)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z", - ), - h.Attribute("fill", "#9E77ED"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175", - ), - h.Attribute("stroke", "#9E77ED"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "41.5625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z", - ), - h.Attribute("fill", "#F7F7F7"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "12.8125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#373A41"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M100 -1H190C196.075 -1 201 3.92487 201 10H199C199 5.02944 194.971 1 190 1H100V-1ZM201 122C201 128.075 196.075 133 190 133H100V131H190C194.971 131 199 126.971 199 122H201ZM100 132V0V132ZM190 -1C196.075 -1 201 3.92487 201 10V122C201 128.075 196.075 133 190 133V131C194.971 131 199 126.971 199 122V10C199 5.02944 194.971 1 190 1V-1Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-32-inside-3_4931_10045)"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_4931_10045"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_4931_10045"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_4931_10045"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_4931_10045"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.linearGradient( - [ - h.Attribute("id", "paint1_linear_4931_10045"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_4931_10045")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip3_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip4_4931_10045")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) - -export const Light = <Message>(h: HtmlBuilder<Message>, className: string): Html => - illustration( - h, - { - width: "200", - height: "133", - viewBox: "0 0 200 133", - fill: "none", - xmlns: "http://www.w3.org/2000/svg", - }, - className, - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_8021_27248)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_8021_27248)")], - [ - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 118) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.625)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 118) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_8021_27248)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_8021_27248"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z", - ), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-6-inside-1_8021_27248)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.8125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#FAFAFA"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_8021_27248"), - h.Style({ "mask-type": "alpha" }), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "58"), - h.Attribute("width", "105"), - h.Attribute("height", "33"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 58.3125)"), - h.Attribute("fill", "url(#paint0_linear_8021_27248)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_8021_27248)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z", - ), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175", - ), - h.Attribute("stroke", "#7F56D9"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "42.0625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z", - ), - h.Attribute("fill", "#181D27"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_8021_27248"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.625H57.5V143H12.5V23.625Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.625V143H57.8125V23.625H57.1875Z"), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-22-inside-2_8021_27248)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30.5"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.875"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "42.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "51.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "77.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "86.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "13.3125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#D5D7DA"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z", - ), - h.Attribute("stroke", "#D5D7DA"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_8021_27248"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_8021_27248"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_8021_27248"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_8021_27248"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_8021_27248")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_8021_27248")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_8021_27248")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) - -export const Dark = <Message>(h: HtmlBuilder<Message>, className: string): Html => - illustration( - h, - { - width: "200", - height: "133", - viewBox: "0 0 200 133", - fill: "none", - xmlns: "http://www.w3.org/2000/svg", - }, - className, - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_8021_27290)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_8021_27290)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 118) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.625)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 118) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_8021_27290)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "#0C0E12"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_8021_27290"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-6-inside-1_8021_27290)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.8125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#13161B"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_8021_27290"), - h.Style({ "mask-type": "alpha" }), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "58"), - h.Attribute("width", "105"), - h.Attribute("height", "33"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 58.3125)"), - h.Attribute("fill", "url(#paint0_linear_8021_27290)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_8021_27290)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z", - ), - h.Attribute("fill", "#9E77ED"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175", - ), - h.Attribute("stroke", "#9E77ED"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "42.0625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z", - ), - h.Attribute("fill", "#F7F7F7"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#373A41"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_8021_27290"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.625H57.5V143H12.5V23.625Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.625V143H57.8125V23.625H57.1875Z"), - h.Attribute("fill", "#22262F"), - h.Attribute("mask", "url(#path-22-inside-2_8021_27290)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30.5"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.875"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "42.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "51.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "77.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "86.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "13.3125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#373A41"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z", - ), - h.Attribute("stroke", "#373A41"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_8021_27290"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_8021_27290"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_8021_27290"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_8021_27290"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_8021_27290")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_8021_27290")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_8021_27290")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) diff --git a/apps/web-foldkit/src/page/my-settings/appearance/view.ts b/apps/web-foldkit/src/page/my-settings/appearance/view.ts index 42c2aa868..91efa5619 100644 --- a/apps/web-foldkit/src/page/my-settings/appearance/view.ts +++ b/apps/web-foldkit/src/page/my-settings/appearance/view.ts @@ -1,6 +1,6 @@ import { Option } from "effect" import { Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { BUILT_IN_PRESETS, COLOR_SWATCHES } from "~/lib/theme/presets" import { ariaRadioGroup } from "../../../ui/aria-radio" @@ -9,7 +9,6 @@ import { view as selectView } from "../../../ui/select-view" import type { PageViewInputs } from "../../contract" import { divider, pageHeader, settingsRow } from "../shared" import { generateIcon, grayPalettePreview, radiusOption, remixOptionCard, themePresetCard } from "./cards" -import { Dark, Light, System } from "./illustrations" import { Message } from "./message" import type { Model, ThemeMode } from "./model" import { RADIUS_PRESETS, SWATCH_COLOR_NAMES, toGrayPalette } from "./presets" @@ -17,14 +16,11 @@ import { GENERATE_TARGET, GRAY_PALETTE_ITEMS, interaction, toGrayPaletteMessage /** Port of `routes/_app/$orgSlug/my-settings/index.tsx` (Appearance). */ -const themeModes: ReadonlyArray<{ - value: ThemeMode - label: string - illustration: <M>(h: HtmlBuilder<M>, className: string) => Html -}> = [ - { value: "system", label: "System preference", illustration: System }, - { value: "light", label: "Light mode", illustration: Light }, - { value: "dark", label: "Dark mode", illustration: Dark }, +/** Static illustrations in `public/images/appearance` (shared with the onboarding theme step). */ +const themeModes: ReadonlyArray<{ value: ThemeMode; label: string; illustration: string }> = [ + { value: "system", label: "System preference", illustration: "/images/appearance/system.svg" }, + { value: "light", label: "Light mode", illustration: "/images/appearance/light.svg" }, + { value: "dark", label: "Dark mode", illustration: "/images/appearance/dark.svg" }, ] const isThemeMode = (value: string): value is ThemeMode => @@ -220,7 +216,12 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, ), ], [ - option.illustration(h, "size-full"), + h.img([ + h.Src(option.illustration), + h.Alt(""), + h.Attribute("draggable", "false"), + h.Class("size-full"), + ]), ...(isSelected ? [ h.div( diff --git a/apps/web-foldkit/src/page/onboarding/steps/theme.ts b/apps/web-foldkit/src/page/onboarding/steps/theme.ts index 9e5318f4e..b8ac9eef3 100644 --- a/apps/web-foldkit/src/page/onboarding/steps/theme.ts +++ b/apps/web-foldkit/src/page/onboarding/steps/theme.ts @@ -1,6 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" -import { appearanceDark, appearanceLight, appearanceSystem } from "../../../ui/appearance-previews" import { visuallyHiddenStyle } from "../../../ui/checkbox" import { Message } from "../message" import type { ThemeState } from "../../../theme" @@ -9,7 +8,6 @@ import { onboardingNavigation, stepHeader } from "../navigation" /** `theme-selection-step.tsx`: React Aria's unstyled RadioGroup/Radio with ColorSwatch and previews. */ - /** `parseColor(hex).getColorName("en-US")` for each swatch. */ const SWATCHES = [ { hex: "#535862", name: "dark grayish blue" }, @@ -22,10 +20,11 @@ const SWATCHES = [ { hex: "#E04F16", name: "vibrant red orange" }, ] as const -const THEMES: ReadonlyArray<{ value: Theme; label: string; preview: (h: HtmlBuilder<Message>) => Html }> = [ - { value: "system", label: "System preference", preview: appearanceSystem }, - { value: "light", label: "Light mode", preview: appearanceLight }, - { value: "dark", label: "Dark mode", preview: appearanceDark }, +/** Static illustrations in `public/images/appearance`. */ +const THEMES: ReadonlyArray<{ value: Theme; label: string; preview: string }> = [ + { value: "system", label: "System preference", preview: "/images/appearance/system.svg" }, + { value: "light", label: "Light mode", preview: "/images/appearance/light.svg" }, + { value: "dark", label: "Dark mode", preview: "/images/appearance/dark.svg" }, ] const rgbOf = (hex: string) => { @@ -155,7 +154,12 @@ const displayPreferences = (h: HtmlBuilder<Message>, theme: ThemeState): Html => ), ], [ - option.preview(h), + h.img([ + h.Src(option.preview), + h.Alt(""), + h.Attribute("draggable", "false"), + h.Class("size-full"), + ]), ...(isSelected ? [ h.div( diff --git a/apps/web-foldkit/src/ui/appearance-previews.ts b/apps/web-foldkit/src/ui/appearance-previews.ts deleted file mode 100644 index 8a0b6850d..000000000 --- a/apps/web-foldkit/src/ui/appearance-previews.ts +++ /dev/null @@ -1,1815 +0,0 @@ -// Originally generated from the React app's appearance illustrations. Edit by hand. -import type { Html, HtmlBuilder } from "foldkit/html" - -/** `System` appearance preview. */ -export const appearanceSystem = <Message>(h: HtmlBuilder<Message>): Html => - h.svg( - [ - h.Attribute("width", "200"), - h.Attribute("height", "132"), - h.Attribute("viewBox", "0 0 200 132"), - h.Attribute("fill", "none"), - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Class("size-full"), - ], - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_4931_10045)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_4931_10045)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 117.5) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.125)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 117.5) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_4931_10045)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z", - ), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-6-inside-1_4931_10045)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_4931_10045)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.125"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.3125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#FAFAFA"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_4931_10045"), - h.Attribute("style", "mask-type: alpha;"), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "57"), - h.Attribute("width", "105"), - h.Attribute("height", "34"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 57.8125)"), - h.Attribute("fill", "url(#paint0_linear_4931_10045)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_4931_10045)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z", - ), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175", - ), - h.Attribute("stroke", "#7F56D9"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "41.5625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z", - ), - h.Attribute("fill", "#181D27"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_4931_10045"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.125H57.5V142.5H12.5V23.125Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.125V142.5H57.8125V23.125H57.1875Z"), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-22-inside-2_4931_10045)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.375"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "41.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "50.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "76.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "85.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "12.8125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#D5D7DA"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M10 0.5H190C195.247 0.5 199.5 4.7533 199.5 10V122C199.5 127.247 195.247 131.5 190 131.5H10C4.7533 131.5 0.5 127.247 0.5 122V10C0.5 4.75329 4.75329 0.5 10 0.5Z", - ), - h.Attribute("stroke", "#D5D7DA"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip3_4931_10045)")], - [ - h.mask( - [ - h.Attribute("id", "path-32-inside-3_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.125)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 117.5) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip4_4931_10045)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "#0C0E12"), - ]), - h.mask( - [ - h.Attribute("id", "path-36-inside-4_4931_10045"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 17.5C12.5 14.7386 14.7386 12.5 17.5 12.5H182.5C185.261 12.5 187.5 14.7386 187.5 17.5V23.125H12.5V17.5Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 12.5H187.5H12.5ZM187.5 23.75H12.5V22.5H187.5V23.75ZM12.5 23.125V12.5V23.125ZM187.5 12.5V23.125V12.5Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-36-inside-4_4931_10045)"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.3125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#13161B"), - ]), - h.mask( - [ - h.Attribute("id", "mask1_4931_10045"), - h.Attribute("style", "mask-type: alpha;"), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "57"), - h.Attribute("width", "105"), - h.Attribute("height", "34"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 57.8125)"), - h.Attribute("fill", "url(#paint1_linear_4931_10045)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask1_4931_10045)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.1255L81.2734 83.5912C81.253 83.5975 81.2321 83.6017 81.211 83.6037L70.2824 84.6602C70.1222 84.6757 70 84.8103 70 84.9712V87.1875C70 87.3601 70.1399 87.5 70.3125 87.5H174.688C174.86 87.5 175 87.3601 175 87.1875V62.983C175 62.8104 174.86 62.6705 174.688 62.6705H171.303C171.268 62.6705 171.233 62.6646 171.2 62.653L160.128 58.7945C160.046 58.7661 159.957 58.7727 159.88 58.8127L148.793 64.6081C148.765 64.6231 148.734 64.6335 148.702 64.639L137.541 66.5838C137.514 66.5885 137.487 66.5968 137.463 66.6083L126.339 71.7769C126.281 71.8038 126.216 71.8123 126.153 71.8014L115.071 69.8704C115.024 69.8622 114.976 69.8649 114.931 69.8781L103.791 73.113C103.764 73.121 103.738 73.1326 103.714 73.1477L92.5341 80.0925C92.5115 80.1065 92.4871 80.1176 92.4617 80.1255Z", - ), - h.Attribute("fill", "#9E77ED"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 84.6875L81.211 83.6037C81.2321 83.6017 81.253 83.5974 81.2734 83.5912L92.4617 80.1255C92.4871 80.1176 92.5115 80.1065 92.5341 80.0925L103.714 73.1477C103.738 73.1326 103.764 73.121 103.791 73.113L114.931 69.8781C114.976 69.8649 115.024 69.8622 115.071 69.8704L126.154 71.8014C126.216 71.8123 126.281 71.8038 126.339 71.7769L137.463 66.6083C137.487 66.5968 137.514 66.5885 137.541 66.5838L148.702 64.639C148.734 64.6335 148.765 64.6231 148.793 64.6081L159.88 58.8127C159.957 58.7727 160.046 58.7661 160.128 58.7945L171.2 62.653C171.233 62.6646 171.268 62.6705 171.303 62.6705H175", - ), - h.Attribute("stroke", "#9E77ED"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.062C63.75 101.991 65.4289 100.312 67.5 100.312H177.5C179.571 100.312 181.25 101.991 181.25 104.063V141.563C181.25 143.634 179.571 145.312 177.5 145.312H67.5C65.4289 145.312 63.75 143.634 63.75 141.562V104.062Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "41.5625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.375H65.0757L66.2158 33.5283H66.2646L67.4048 31.375H68.5889L66.7651 34.6074V36.375H65.7153V34.6074L63.8916 31.375ZM70.0555 36.4482C69.6763 36.4482 69.3483 36.3677 69.0717 36.2065C68.7966 36.0438 68.5842 35.8175 68.4344 35.5278C68.2847 35.2365 68.2098 34.8988 68.2098 34.5146C68.2098 34.1273 68.2847 33.7887 68.4344 33.499C68.5842 33.2077 68.7966 32.9814 69.0717 32.8203C69.3483 32.6576 69.6763 32.5762 70.0555 32.5762C70.4348 32.5762 70.7619 32.6576 71.037 32.8203C71.3137 32.9814 71.5269 33.2077 71.6766 33.499C71.8264 33.7887 71.9012 34.1273 71.9012 34.5146C71.9012 34.8988 71.8264 35.2365 71.6766 35.5278C71.5269 35.8175 71.3137 36.0438 71.037 36.2065C70.7619 36.3677 70.4348 36.4482 70.0555 36.4482ZM70.0604 35.6426C70.233 35.6426 70.377 35.5937 70.4926 35.4961C70.6081 35.3968 70.6952 35.2617 70.7538 35.0908C70.814 34.9199 70.8441 34.7254 70.8441 34.5073C70.8441 34.2892 70.814 34.0947 70.7538 33.9238C70.6952 33.7529 70.6081 33.6178 70.4926 33.5186C70.377 33.4193 70.233 33.3696 70.0604 33.3696C69.8863 33.3696 69.7398 33.4193 69.621 33.5186C69.5038 33.6178 69.4151 33.7529 69.3549 33.9238C69.2963 34.0947 69.267 34.2892 69.267 34.5073C69.267 34.7254 69.2963 34.9199 69.3549 35.0908C69.4151 35.2617 69.5038 35.3968 69.621 35.4961C69.7398 35.5937 69.8863 35.6426 70.0604 35.6426ZM74.9823 34.7783V32.625H76.0223V36.375H75.0238V35.6938H74.9847C74.9001 35.9136 74.7593 36.0902 74.5624 36.2236C74.3671 36.3571 74.1286 36.4238 73.847 36.4238C73.5964 36.4238 73.3759 36.3669 73.1854 36.2529C72.995 36.139 72.8461 35.9771 72.7386 35.7671C72.6329 35.5571 72.5791 35.3057 72.5775 35.0127V32.625H73.6176V34.8271C73.6192 35.0485 73.6786 35.2235 73.7958 35.3521C73.913 35.4806 74.07 35.5449 74.267 35.5449C74.3923 35.5449 74.5095 35.5164 74.6185 35.4595C74.7276 35.4009 74.8155 35.3146 74.8822 35.2007C74.9506 35.0868 74.9839 34.946 74.9823 34.7783ZM76.8542 36.375V32.625H77.8625V33.2793H77.9016C77.97 33.0465 78.0847 32.8708 78.2458 32.752C78.407 32.6315 78.5925 32.5713 78.8025 32.5713C78.8546 32.5713 78.9107 32.5745 78.9709 32.5811C79.0312 32.5876 79.0841 32.5965 79.1296 32.6079V33.5308C79.0808 33.5161 79.0133 33.5031 78.927 33.4917C78.8407 33.4803 78.7618 33.4746 78.6902 33.4746C78.5372 33.4746 78.4005 33.508 78.28 33.5747C78.1612 33.6398 78.0668 33.731 77.9968 33.8481C77.9285 33.9653 77.8943 34.1004 77.8943 34.2534V36.375H76.8542ZM82.6404 36.436C82.3556 36.436 82.0976 36.3628 81.8665 36.2163C81.637 36.0682 81.4547 35.8509 81.3196 35.5645C81.1861 35.2764 81.1194 34.9232 81.1194 34.5049C81.1194 34.0752 81.1886 33.7179 81.3269 33.4331C81.4653 33.1466 81.6492 32.9326 81.8787 32.791C82.1098 32.6478 82.3629 32.5762 82.6379 32.5762C82.8479 32.5762 83.0229 32.612 83.1628 32.6836C83.3044 32.7536 83.4184 32.8415 83.5046 32.9473C83.5925 33.0514 83.6593 33.154 83.7048 33.2549H83.7366V31.375H84.7742V36.375H83.7488V35.7744H83.7048C83.656 35.8786 83.5868 35.9819 83.4973 36.0845C83.4094 36.1854 83.2947 36.2692 83.1531 36.3359C83.0131 36.4027 82.8422 36.436 82.6404 36.436ZM82.97 35.6084C83.1376 35.6084 83.2792 35.5628 83.3948 35.4717C83.512 35.3789 83.6015 35.2495 83.6633 35.0835C83.7268 34.9175 83.7585 34.723 83.7585 34.5C83.7585 34.277 83.7276 34.0833 83.6658 33.9189C83.6039 33.7546 83.5144 33.6276 83.3972 33.5381C83.28 33.4486 83.1376 33.4038 82.97 33.4038C82.7991 33.4038 82.655 33.4502 82.5378 33.543C82.4207 33.6357 82.3319 33.7643 82.2717 33.9287C82.2115 34.0931 82.1814 34.2835 82.1814 34.5C82.1814 34.7181 82.2115 34.911 82.2717 35.0786C82.3336 35.2446 82.4223 35.3748 82.5378 35.4692C82.655 35.562 82.7991 35.6084 82.97 35.6084ZM86.6986 36.4458C86.4594 36.4458 86.2461 36.4043 86.059 36.3213C85.8718 36.2367 85.7237 36.1121 85.6146 35.9478C85.5072 35.7817 85.4535 35.575 85.4535 35.3276C85.4535 35.1193 85.4917 34.9443 85.5682 34.8027C85.6447 34.6611 85.7489 34.5472 85.8807 34.4609C86.0126 34.3747 86.1623 34.3096 86.33 34.2656C86.4992 34.2217 86.6766 34.1908 86.8622 34.1729C87.0803 34.1501 87.2561 34.1289 87.3895 34.1094C87.523 34.0882 87.6198 34.0573 87.6801 34.0166C87.7403 33.9759 87.7704 33.9157 87.7704 33.8359V33.8213C87.7704 33.6667 87.7216 33.547 87.6239 33.4624C87.5279 33.3778 87.3912 33.3354 87.2137 33.3354C87.0266 33.3354 86.8776 33.377 86.767 33.46C86.6563 33.5413 86.583 33.6439 86.5472 33.7676L85.5853 33.6895C85.6342 33.4616 85.7302 33.2646 85.8734 33.0986C86.0166 32.931 86.2014 32.8024 86.4276 32.7129C86.6555 32.6217 86.9191 32.5762 87.2186 32.5762C87.427 32.5762 87.6263 32.6006 87.8168 32.6494C88.0088 32.6982 88.1789 32.7739 88.327 32.8765C88.4768 32.979 88.5948 33.1108 88.681 33.272C88.7673 33.4315 88.8104 33.6227 88.8104 33.8457V36.375H87.8241V35.855H87.7948C87.7346 35.9722 87.654 36.0755 87.5531 36.165C87.4522 36.2529 87.3309 36.3221 87.1893 36.3726C87.0477 36.4214 86.8842 36.4458 86.6986 36.4458ZM86.9965 35.728C87.1495 35.728 87.2845 35.6979 87.4017 35.6377C87.5189 35.5758 87.6109 35.4928 87.6776 35.3887C87.7443 35.2845 87.7777 35.1665 87.7777 35.0347V34.6367C87.7452 34.6579 87.7004 34.6774 87.6434 34.6953C87.5881 34.7116 87.5254 34.7271 87.4554 34.7417C87.3855 34.7547 87.3155 34.7669 87.2455 34.7783C87.1755 34.7881 87.112 34.797 87.0551 34.8052C86.933 34.8231 86.8264 34.8516 86.7352 34.8906C86.6441 34.9297 86.5733 34.9826 86.5228 35.0493C86.4724 35.1144 86.4471 35.1958 86.4471 35.2935C86.4471 35.4351 86.4984 35.5433 86.601 35.6182C86.7051 35.6914 86.837 35.728 86.9965 35.728ZM92.7374 33.6943L91.7853 33.7529C91.769 33.6715 91.734 33.5983 91.6803 33.5332C91.6266 33.4665 91.5558 33.4136 91.4679 33.3745C91.3816 33.3338 91.2783 33.3135 91.1578 33.3135C90.9967 33.3135 90.8608 33.3477 90.7501 33.416C90.6394 33.4827 90.5841 33.5723 90.5841 33.6846C90.5841 33.7741 90.6199 33.8498 90.6915 33.9116C90.7631 33.9735 90.886 34.0231 91.0602 34.0605L91.7389 34.1973C92.1035 34.2721 92.3753 34.3926 92.5543 34.5586C92.7334 34.7246 92.8229 34.9427 92.8229 35.2129C92.8229 35.4587 92.7504 35.6743 92.6056 35.8599C92.4624 36.0454 92.2654 36.1903 92.0148 36.2944C91.7657 36.397 91.4785 36.4482 91.153 36.4482C90.6565 36.4482 90.261 36.3449 89.9664 36.1382C89.6735 35.9299 89.5017 35.6466 89.4513 35.2886L90.4742 35.2349C90.5052 35.3862 90.58 35.5018 90.6989 35.5815C90.8177 35.6597 90.9698 35.6987 91.1554 35.6987C91.3377 35.6987 91.4842 35.6637 91.5948 35.5937C91.7072 35.5221 91.7641 35.4302 91.7657 35.3179C91.7641 35.2235 91.7242 35.1462 91.6461 35.0859C91.568 35.0241 91.4476 34.9769 91.2848 34.9443L90.6354 34.8149C90.2692 34.7417 89.9965 34.6147 89.8175 34.4341C89.6401 34.2534 89.5514 34.0231 89.5514 33.7432C89.5514 33.5023 89.6165 33.2948 89.7467 33.1206C89.8785 32.9465 90.0633 32.8122 90.3009 32.7178C90.5402 32.6234 90.8201 32.5762 91.1407 32.5762C91.6144 32.5762 91.9871 32.6763 92.2589 32.8765C92.5323 33.0767 92.6919 33.3493 92.7374 33.6943ZM94.5178 34.207V36.375H93.4778V31.375H94.4885V33.2866H94.5325C94.6171 33.0653 94.7538 32.8919 94.9426 32.7666C95.1314 32.6396 95.3682 32.5762 95.6531 32.5762C95.9135 32.5762 96.1405 32.6331 96.3342 32.7471C96.5295 32.8594 96.6809 33.0213 96.7883 33.2329C96.8974 33.4429 96.9511 33.6943 96.9495 33.9873V36.375H95.9094V34.1729C95.9111 33.9417 95.8525 33.7619 95.7336 33.6333C95.6165 33.5047 95.4521 33.4404 95.2405 33.4404C95.0989 33.4404 94.9736 33.4705 94.8645 33.5308C94.7571 33.591 94.6724 33.6789 94.6106 33.7944C94.5504 33.9084 94.5194 34.0459 94.5178 34.207ZM97.7875 36.375V31.375H98.8275V33.2549H98.8593C98.9048 33.154 98.9707 33.0514 99.057 32.9473C99.1449 32.8415 99.2588 32.7536 99.3988 32.6836C99.5404 32.612 99.7162 32.5762 99.9261 32.5762C100.2 32.5762 100.452 32.6478 100.683 32.791C100.914 32.9326 101.099 33.1466 101.237 33.4331C101.376 33.7179 101.445 34.0752 101.445 34.5049C101.445 34.9232 101.377 35.2764 101.242 35.5645C101.109 35.8509 100.926 36.0682 100.695 36.2163C100.466 36.3628 100.209 36.436 99.9237 36.436C99.7219 36.436 99.5502 36.4027 99.4086 36.3359C99.2686 36.2692 99.1538 36.1854 99.0643 36.0845C98.9748 35.9819 98.9065 35.8786 98.8593 35.7744H98.8129V36.375H97.7875ZM98.8055 34.5C98.8055 34.723 98.8365 34.9175 98.8983 35.0835C98.9602 35.2495 99.0497 35.3789 99.1669 35.4717C99.2841 35.5628 99.4265 35.6084 99.5941 35.6084C99.7634 35.6084 99.9066 35.562 100.024 35.4692C100.141 35.3748 100.23 35.2446 100.29 35.0786C100.352 34.911 100.383 34.7181 100.383 34.5C100.383 34.2835 100.353 34.0931 100.292 33.9287C100.232 33.7643 100.143 33.6357 100.026 33.543C99.9091 33.4502 99.765 33.4038 99.5941 33.4038C99.4248 33.4038 99.2816 33.4486 99.1644 33.5381C99.0489 33.6276 98.9602 33.7546 98.8983 33.9189C98.8365 34.0833 98.8055 34.277 98.8055 34.5ZM103.833 36.4482C103.454 36.4482 103.126 36.3677 102.849 36.2065C102.574 36.0438 102.362 35.8175 102.212 35.5278C102.062 35.2365 101.987 34.8988 101.987 34.5146C101.987 34.1273 102.062 33.7887 102.212 33.499C102.362 33.2077 102.574 32.9814 102.849 32.8203C103.126 32.6576 103.454 32.5762 103.833 32.5762C104.212 32.5762 104.539 32.6576 104.814 32.8203C105.091 32.9814 105.304 33.2077 105.454 33.499C105.604 33.7887 105.679 34.1273 105.679 34.5146C105.679 34.8988 105.604 35.2365 105.454 35.5278C105.304 35.8175 105.091 36.0438 104.814 36.2065C104.539 36.3677 104.212 36.4482 103.833 36.4482ZM103.838 35.6426C104.01 35.6426 104.154 35.5937 104.27 35.4961C104.386 35.3968 104.473 35.2617 104.531 35.0908C104.591 34.9199 104.622 34.7254 104.622 34.5073C104.622 34.2892 104.591 34.0947 104.531 33.9238C104.473 33.7529 104.386 33.6178 104.27 33.5186C104.154 33.4193 104.01 33.3696 103.838 33.3696C103.664 33.3696 103.517 33.4193 103.398 33.5186C103.281 33.6178 103.193 33.7529 103.132 33.9238C103.074 34.0947 103.044 34.2892 103.044 34.5073C103.044 34.7254 103.074 34.9199 103.132 35.0908C103.193 35.2617 103.281 35.3968 103.398 35.4961C103.517 35.5937 103.664 35.6426 103.838 35.6426ZM107.424 36.4458C107.185 36.4458 106.972 36.4043 106.785 36.3213C106.597 36.2367 106.449 36.1121 106.34 35.9478C106.233 35.7817 106.179 35.575 106.179 35.3276C106.179 35.1193 106.217 34.9443 106.294 34.8027C106.37 34.6611 106.475 34.5472 106.606 34.4609C106.738 34.3747 106.888 34.3096 107.056 34.2656C107.225 34.2217 107.402 34.1908 107.588 34.1729C107.806 34.1501 107.982 34.1289 108.115 34.1094C108.249 34.0882 108.346 34.0573 108.406 34.0166C108.466 33.9759 108.496 33.9157 108.496 33.8359V33.8213C108.496 33.6667 108.447 33.547 108.35 33.4624C108.254 33.3778 108.117 33.3354 107.939 33.3354C107.752 33.3354 107.603 33.377 107.493 33.46C107.382 33.5413 107.309 33.6439 107.273 33.7676L106.311 33.6895C106.36 33.4616 106.456 33.2646 106.599 33.0986C106.742 32.931 106.927 32.8024 107.153 32.7129C107.381 32.6217 107.645 32.5762 107.944 32.5762C108.153 32.5762 108.352 32.6006 108.542 32.6494C108.735 32.6982 108.905 32.7739 109.053 32.8765C109.202 32.979 109.32 33.1108 109.407 33.272C109.493 33.4315 109.536 33.6227 109.536 33.8457V36.375H108.55V35.855H108.521C108.46 35.9722 108.38 36.0755 108.279 36.165C108.178 36.2529 108.057 36.3221 107.915 36.3726C107.773 36.4214 107.61 36.4458 107.424 36.4458ZM107.722 35.728C107.875 35.728 108.01 35.6979 108.127 35.6377C108.245 35.5758 108.337 35.4928 108.403 35.3887C108.47 35.2845 108.503 35.1665 108.503 35.0347V34.6367C108.471 34.6579 108.426 34.6774 108.369 34.6953C108.314 34.7116 108.251 34.7271 108.181 34.7417C108.111 34.7547 108.041 34.7669 107.971 34.7783C107.901 34.7881 107.838 34.797 107.781 34.8052C107.659 34.8231 107.552 34.8516 107.461 34.8906C107.37 34.9297 107.299 34.9826 107.249 35.0493C107.198 35.1144 107.173 35.1958 107.173 35.2935C107.173 35.4351 107.224 35.5433 107.327 35.6182C107.431 35.6914 107.563 35.728 107.722 35.728ZM110.343 36.375V32.625H111.351V33.2793H111.39C111.459 33.0465 111.573 32.8708 111.735 32.752C111.896 32.6315 112.081 32.5713 112.291 32.5713C112.343 32.5713 112.399 32.5745 112.46 32.5811C112.52 32.5876 112.573 32.5965 112.618 32.6079V33.5308C112.57 33.5161 112.502 33.5031 112.416 33.4917C112.33 33.4803 112.251 33.4746 112.179 33.4746C112.026 33.4746 111.889 33.508 111.769 33.5747C111.65 33.6398 111.556 33.731 111.486 33.8481C111.417 33.9653 111.383 34.1004 111.383 34.2534V36.375H110.343ZM114.363 36.436C114.079 36.436 113.821 36.3628 113.589 36.2163C113.36 36.0682 113.178 35.8509 113.043 35.5645C112.909 35.2764 112.842 34.9232 112.842 34.5049C112.842 34.0752 112.912 33.7179 113.05 33.4331C113.188 33.1466 113.372 32.9326 113.602 32.791C113.833 32.6478 114.086 32.5762 114.361 32.5762C114.571 32.5762 114.746 32.612 114.886 32.6836C115.027 32.7536 115.141 32.8415 115.228 32.9473C115.316 33.0514 115.382 33.154 115.428 33.2549H115.46V31.375H116.497V36.375H115.472V35.7744H115.428C115.379 35.8786 115.31 35.9819 115.22 36.0845C115.132 36.1854 115.018 36.2692 114.876 36.3359C114.736 36.4027 114.565 36.436 114.363 36.436ZM114.693 35.6084C114.861 35.6084 115.002 35.5628 115.118 35.4717C115.235 35.3789 115.325 35.2495 115.386 35.0835C115.45 34.9175 115.482 34.723 115.482 34.5C115.482 34.277 115.451 34.0833 115.389 33.9189C115.327 33.7546 115.237 33.6276 115.12 33.5381C115.003 33.4486 114.861 33.4038 114.693 33.4038C114.522 33.4038 114.378 33.4502 114.261 33.543C114.144 33.6357 114.055 33.7643 113.995 33.9287C113.935 34.0931 113.904 34.2835 113.904 34.5C113.904 34.7181 113.935 34.911 113.995 35.0786C114.057 35.2446 114.145 35.3748 114.261 35.4692C114.378 35.562 114.522 35.6084 114.693 35.6084Z", - ), - h.Attribute("fill", "#F7F7F7"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.375"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "12.8125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#373A41"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M100 -1H190C196.075 -1 201 3.92487 201 10H199C199 5.02944 194.971 1 190 1H100V-1ZM201 122C201 128.075 196.075 133 190 133H100V131H190C194.971 131 199 126.971 199 122H201ZM100 132V0V132ZM190 -1C196.075 -1 201 3.92487 201 10V122C201 128.075 196.075 133 190 133V131C194.971 131 199 126.971 199 122V10C199 5.02944 194.971 1 190 1V-1Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-32-inside-3_4931_10045)"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_4931_10045"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_4931_10045"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_4931_10045"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.25"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_4931_10045"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_4931_10045"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.linearGradient( - [ - h.Attribute("id", "paint1_linear_4931_10045"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M0 10C0 4.47715 4.47715 0 10 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H10C4.47716 132 0 127.523 0 122V10Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_4931_10045")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip3_4931_10045")], - [ - h.path([ - h.Attribute( - "d", - "M100 0H190C195.523 0 200 4.47715 200 10V122C200 127.523 195.523 132 190 132H100V0Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip4_4931_10045")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "12.5"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) - -/** `Light` appearance preview. */ -export const appearanceLight = <Message>(h: HtmlBuilder<Message>): Html => - h.svg( - [ - h.Attribute("width", "200"), - h.Attribute("height", "133"), - h.Attribute("viewBox", "0 0 200 133"), - h.Attribute("fill", "none"), - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Class("size-full"), - ], - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_8021_27248)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_8021_27248)")], - [ - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 118) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.625)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 118) rotate(-90)"), - h.Attribute("fill", "#E9EAEB"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_8021_27248)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_8021_27248"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z", - ), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-6-inside-1_8021_27248)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_8021_27248)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.8125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#FAFAFA"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_8021_27248"), - h.Attribute("style", "mask-type: alpha;"), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "58"), - h.Attribute("width", "105"), - h.Attribute("height", "33"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 58.3125)"), - h.Attribute("fill", "url(#paint0_linear_8021_27248)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_8021_27248)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z", - ), - h.Attribute("fill", "#7F56D9"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175", - ), - h.Attribute("stroke", "#7F56D9"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z", - ), - h.Attribute("fill", "#FAFAFA"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "42.0625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z", - ), - h.Attribute("fill", "#181D27"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#7F56D9"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_8021_27248"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.625H57.5V143H12.5V23.625Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.625V143H57.8125V23.625H57.1875Z"), - h.Attribute("fill", "#E9EAEB"), - h.Attribute("mask", "url(#path-22-inside-2_8021_27248)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30.5"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.875"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "42.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#7F56D9"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "51.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "77.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "86.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#F5F5F5"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "13.3125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#D5D7DA"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z", - ), - h.Attribute("stroke", "#D5D7DA"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_8021_27248"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_8021_27248"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_8021_27248"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27248"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_8021_27248"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_8021_27248")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_8021_27248")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_8021_27248")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) - -/** `Dark` appearance preview. */ -export const appearanceDark = <Message>(h: HtmlBuilder<Message>): Html => - h.svg( - [ - h.Attribute("width", "200"), - h.Attribute("height", "133"), - h.Attribute("viewBox", "0 0 200 133"), - h.Attribute("fill", "none"), - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Class("size-full"), - ], - [ - h.g( - [h.Attribute("clip-path", "url(#clip0_8021_27290)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#F5F5F5"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip1_8021_27290)")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(13.125 118) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "200"), - h.Attribute("height", "1.25"), - h.Attribute("transform", "translate(0 13.625)"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("width", "117.5"), - h.Attribute("height", "1.25001"), - h.Attribute("transform", "translate(185.625 118) rotate(-90)"), - h.Attribute("fill", "#373A41"), - ]), - h.g( - [h.Attribute("clip-path", "url(#clip2_8021_27290)")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "#0C0E12"), - ]), - h.mask( - [ - h.Attribute("id", "path-6-inside-1_8021_27290"), - h.Attribute("fill", "white"), - ], - [ - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M12.5 18C12.5 15.2386 14.7386 13 17.5 13H182.5C185.261 13 187.5 15.2386 187.5 18V23.625H12.5V18Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.path([ - h.Attribute( - "d", - "M12.5 13H187.5H12.5ZM187.5 24.25H12.5V23H187.5V24.25ZM12.5 23.625V13V23.625ZM187.5 13V23.625V13Z", - ), - h.Attribute("fill", "#373A41"), - h.Attribute("mask", "url(#path-6-inside-1_8021_27290)"), - ]), - h.g( - [h.Attribute("filter", "url(#filter0_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#EE6A5F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "18.75"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#CE5347"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter1_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#F5BD4F"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#D6A243"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.g( - [h.Attribute("filter", "url(#filter2_i_8021_27290)")], - [ - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.875"), - h.Attribute("fill", "#61C454"), - ]), - ], - ), - h.circle([ - h.Attribute("cx", "31.25"), - h.Attribute("cy", "18.625"), - h.Attribute("r", "1.79688"), - h.Attribute("stroke", "#58A942"), - h.Attribute("stroke-width", "0.15625"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "50.8125"), - h.Attribute("width", "117.5"), - h.Attribute("height", "45"), - h.Attribute("rx", "3.75"), - h.Attribute("fill", "#13161B"), - ]), - h.mask( - [ - h.Attribute("id", "mask0_8021_27290"), - h.Attribute("style", "mask-type: alpha;"), - h.Attribute("maskUnits", "userSpaceOnUse"), - h.Attribute("x", "70"), - h.Attribute("y", "58"), - h.Attribute("width", "105"), - h.Attribute("height", "33"), - ], - [ - h.rect([ - h.Attribute("width", "105"), - h.Attribute("height", "32.5"), - h.Attribute("transform", "translate(70 58.3125)"), - h.Attribute("fill", "url(#paint0_linear_8021_27290)"), - ]), - ], - ), - h.g( - [h.Attribute("mask", "url(#mask0_8021_27290)")], - [ - h.path([ - h.Attribute("opacity", "0.1"), - h.Attribute( - "d", - "M92.4617 80.6255L81.2734 84.0912C81.253 84.0975 81.2321 84.1017 81.211 84.1037L70.2824 85.1602C70.1222 85.1757 70 85.3103 70 85.4712V87.6875C70 87.8601 70.1399 88 70.3125 88H174.688C174.86 88 175 87.8601 175 87.6875V63.483C175 63.3104 174.86 63.1705 174.688 63.1705H171.303C171.268 63.1705 171.233 63.1646 171.2 63.153L160.128 59.2945C160.046 59.2661 159.957 59.2727 159.88 59.3127L148.793 65.1081C148.765 65.1231 148.734 65.1335 148.702 65.139L137.541 67.0838C137.514 67.0885 137.487 67.0968 137.463 67.1083L126.339 72.2769C126.281 72.3038 126.216 72.3123 126.153 72.3014L115.071 70.3704C115.024 70.3622 114.976 70.3649 114.931 70.3781L103.791 73.613C103.764 73.621 103.738 73.6326 103.714 73.6477L92.5341 80.5925C92.5115 80.6065 92.4871 80.6176 92.4617 80.6255Z", - ), - h.Attribute("fill", "#9E77ED"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M70 85.1875L81.211 84.1037C81.2321 84.1017 81.253 84.0974 81.2734 84.0912L92.4617 80.6255C92.4871 80.6176 92.5115 80.6065 92.5341 80.5925L103.714 73.6477C103.738 73.6326 103.764 73.621 103.791 73.613L114.931 70.3781C114.976 70.3649 115.024 70.3622 115.071 70.3704L126.154 72.3014C126.216 72.3123 126.281 72.3038 126.339 72.2769L137.463 67.1083C137.487 67.0968 137.514 67.0885 137.541 67.0838L148.702 65.139C148.734 65.1335 148.765 65.1231 148.793 65.1081L159.88 59.3127C159.957 59.2727 160.046 59.2661 160.128 59.2945L171.2 63.153C171.233 63.1646 171.268 63.1705 171.303 63.1705H175", - ), - h.Attribute("stroke", "#9E77ED"), - h.Attribute("stroke-width", "0.625"), - h.Attribute("stroke-linecap", "round"), - h.Attribute("stroke-linejoin", "round"), - ]), - h.path([ - h.Attribute( - "d", - "M63.75 104.562C63.75 102.491 65.4289 100.812 67.5 100.812H177.5C179.571 100.812 181.25 102.491 181.25 104.562V142.062C181.25 144.134 179.571 145.812 177.5 145.812H67.5C65.4289 145.812 63.75 144.134 63.75 142.062V104.562Z", - ), - h.Attribute("fill", "#13161B"), - ]), - h.rect([ - h.Attribute("x", "63.75"), - h.Attribute("y", "42.0625"), - h.Attribute("width", "65"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.path([ - h.Attribute( - "d", - "M63.8916 31.875H65.0757L66.2158 34.0283H66.2646L67.4048 31.875H68.5889L66.7651 35.1074V36.875H65.7153V35.1074L63.8916 31.875ZM70.0555 36.9482C69.6763 36.9482 69.3483 36.8677 69.0717 36.7065C68.7966 36.5438 68.5842 36.3175 68.4344 36.0278C68.2847 35.7365 68.2098 35.3988 68.2098 35.0146C68.2098 34.6273 68.2847 34.2887 68.4344 33.999C68.5842 33.7077 68.7966 33.4814 69.0717 33.3203C69.3483 33.1576 69.6763 33.0762 70.0555 33.0762C70.4348 33.0762 70.7619 33.1576 71.037 33.3203C71.3137 33.4814 71.5269 33.7077 71.6766 33.999C71.8264 34.2887 71.9012 34.6273 71.9012 35.0146C71.9012 35.3988 71.8264 35.7365 71.6766 36.0278C71.5269 36.3175 71.3137 36.5438 71.037 36.7065C70.7619 36.8677 70.4348 36.9482 70.0555 36.9482ZM70.0604 36.1426C70.233 36.1426 70.377 36.0937 70.4926 35.9961C70.6081 35.8968 70.6952 35.7617 70.7538 35.5908C70.814 35.4199 70.8441 35.2254 70.8441 35.0073C70.8441 34.7892 70.814 34.5947 70.7538 34.4238C70.6952 34.2529 70.6081 34.1178 70.4926 34.0186C70.377 33.9193 70.233 33.8696 70.0604 33.8696C69.8863 33.8696 69.7398 33.9193 69.621 34.0186C69.5038 34.1178 69.4151 34.2529 69.3549 34.4238C69.2963 34.5947 69.267 34.7892 69.267 35.0073C69.267 35.2254 69.2963 35.4199 69.3549 35.5908C69.4151 35.7617 69.5038 35.8968 69.621 35.9961C69.7398 36.0937 69.8863 36.1426 70.0604 36.1426ZM74.9823 35.2783V33.125H76.0223V36.875H75.0238V36.1938H74.9847C74.9001 36.4136 74.7593 36.5902 74.5624 36.7236C74.3671 36.8571 74.1286 36.9238 73.847 36.9238C73.5964 36.9238 73.3759 36.8669 73.1854 36.7529C72.995 36.639 72.8461 36.4771 72.7386 36.2671C72.6329 36.0571 72.5791 35.8057 72.5775 35.5127V33.125H73.6176V35.3271C73.6192 35.5485 73.6786 35.7235 73.7958 35.8521C73.913 35.9806 74.07 36.0449 74.267 36.0449C74.3923 36.0449 74.5095 36.0164 74.6185 35.9595C74.7276 35.9009 74.8155 35.8146 74.8822 35.7007C74.9506 35.5868 74.9839 35.446 74.9823 35.2783ZM76.8542 36.875V33.125H77.8625V33.7793H77.9016C77.97 33.5465 78.0847 33.3708 78.2458 33.252C78.407 33.1315 78.5925 33.0713 78.8025 33.0713C78.8546 33.0713 78.9107 33.0745 78.9709 33.0811C79.0312 33.0876 79.0841 33.0965 79.1296 33.1079V34.0308C79.0808 34.0161 79.0133 34.0031 78.927 33.9917C78.8407 33.9803 78.7618 33.9746 78.6902 33.9746C78.5372 33.9746 78.4005 34.008 78.28 34.0747C78.1612 34.1398 78.0668 34.231 77.9968 34.3481C77.9285 34.4653 77.8943 34.6004 77.8943 34.7534V36.875H76.8542ZM82.6404 36.936C82.3556 36.936 82.0976 36.8628 81.8665 36.7163C81.637 36.5682 81.4547 36.3509 81.3196 36.0645C81.1861 35.7764 81.1194 35.4232 81.1194 35.0049C81.1194 34.5752 81.1886 34.2179 81.3269 33.9331C81.4653 33.6466 81.6492 33.4326 81.8787 33.291C82.1098 33.1478 82.3629 33.0762 82.6379 33.0762C82.8479 33.0762 83.0229 33.112 83.1628 33.1836C83.3044 33.2536 83.4184 33.3415 83.5046 33.4473C83.5925 33.5514 83.6593 33.654 83.7048 33.7549H83.7366V31.875H84.7742V36.875H83.7488V36.2744H83.7048C83.656 36.3786 83.5868 36.4819 83.4973 36.5845C83.4094 36.6854 83.2947 36.7692 83.1531 36.8359C83.0131 36.9027 82.8422 36.936 82.6404 36.936ZM82.97 36.1084C83.1376 36.1084 83.2792 36.0628 83.3948 35.9717C83.512 35.8789 83.6015 35.7495 83.6633 35.5835C83.7268 35.4175 83.7585 35.223 83.7585 35C83.7585 34.777 83.7276 34.5833 83.6658 34.4189C83.6039 34.2546 83.5144 34.1276 83.3972 34.0381C83.28 33.9486 83.1376 33.9038 82.97 33.9038C82.7991 33.9038 82.655 33.9502 82.5378 34.043C82.4207 34.1357 82.3319 34.2643 82.2717 34.4287C82.2115 34.5931 82.1814 34.7835 82.1814 35C82.1814 35.2181 82.2115 35.411 82.2717 35.5786C82.3336 35.7446 82.4223 35.8748 82.5378 35.9692C82.655 36.062 82.7991 36.1084 82.97 36.1084ZM86.6986 36.9458C86.4594 36.9458 86.2461 36.9043 86.059 36.8213C85.8718 36.7367 85.7237 36.6121 85.6146 36.4478C85.5072 36.2817 85.4535 36.075 85.4535 35.8276C85.4535 35.6193 85.4917 35.4443 85.5682 35.3027C85.6447 35.1611 85.7489 35.0472 85.8807 34.9609C86.0126 34.8747 86.1623 34.8096 86.33 34.7656C86.4992 34.7217 86.6766 34.6908 86.8622 34.6729C87.0803 34.6501 87.2561 34.6289 87.3895 34.6094C87.523 34.5882 87.6198 34.5573 87.6801 34.5166C87.7403 34.4759 87.7704 34.4157 87.7704 34.3359V34.3213C87.7704 34.1667 87.7216 34.047 87.6239 33.9624C87.5279 33.8778 87.3912 33.8354 87.2137 33.8354C87.0266 33.8354 86.8776 33.877 86.767 33.96C86.6563 34.0413 86.583 34.1439 86.5472 34.2676L85.5853 34.1895C85.6342 33.9616 85.7302 33.7646 85.8734 33.5986C86.0166 33.431 86.2014 33.3024 86.4276 33.2129C86.6555 33.1217 86.9191 33.0762 87.2186 33.0762C87.427 33.0762 87.6263 33.1006 87.8168 33.1494C88.0088 33.1982 88.1789 33.2739 88.327 33.3765C88.4768 33.479 88.5948 33.6108 88.681 33.772C88.7673 33.9315 88.8104 34.1227 88.8104 34.3457V36.875H87.8241V36.355H87.7948C87.7346 36.4722 87.654 36.5755 87.5531 36.665C87.4522 36.7529 87.3309 36.8221 87.1893 36.8726C87.0477 36.9214 86.8842 36.9458 86.6986 36.9458ZM86.9965 36.228C87.1495 36.228 87.2845 36.1979 87.4017 36.1377C87.5189 36.0758 87.6109 35.9928 87.6776 35.8887C87.7443 35.7845 87.7777 35.6665 87.7777 35.5347V35.1367C87.7452 35.1579 87.7004 35.1774 87.6434 35.1953C87.5881 35.2116 87.5254 35.2271 87.4554 35.2417C87.3855 35.2547 87.3155 35.2669 87.2455 35.2783C87.1755 35.2881 87.112 35.297 87.0551 35.3052C86.933 35.3231 86.8264 35.3516 86.7352 35.3906C86.6441 35.4297 86.5733 35.4826 86.5228 35.5493C86.4724 35.6144 86.4471 35.6958 86.4471 35.7935C86.4471 35.9351 86.4984 36.0433 86.601 36.1182C86.7051 36.1914 86.837 36.228 86.9965 36.228ZM92.7374 34.1943L91.7853 34.2529C91.769 34.1715 91.734 34.0983 91.6803 34.0332C91.6266 33.9665 91.5558 33.9136 91.4679 33.8745C91.3816 33.8338 91.2783 33.8135 91.1578 33.8135C90.9967 33.8135 90.8608 33.8477 90.7501 33.916C90.6394 33.9827 90.5841 34.0723 90.5841 34.1846C90.5841 34.2741 90.6199 34.3498 90.6915 34.4116C90.7631 34.4735 90.886 34.5231 91.0602 34.5605L91.7389 34.6973C92.1035 34.7721 92.3753 34.8926 92.5543 35.0586C92.7334 35.2246 92.8229 35.4427 92.8229 35.7129C92.8229 35.9587 92.7504 36.1743 92.6056 36.3599C92.4624 36.5454 92.2654 36.6903 92.0148 36.7944C91.7657 36.897 91.4785 36.9482 91.153 36.9482C90.6565 36.9482 90.261 36.8449 89.9664 36.6382C89.6735 36.4299 89.5017 36.1466 89.4513 35.7886L90.4742 35.7349C90.5052 35.8862 90.58 36.0018 90.6989 36.0815C90.8177 36.1597 90.9698 36.1987 91.1554 36.1987C91.3377 36.1987 91.4842 36.1637 91.5948 36.0937C91.7072 36.0221 91.7641 35.9302 91.7657 35.8179C91.7641 35.7235 91.7242 35.6462 91.6461 35.5859C91.568 35.5241 91.4476 35.4769 91.2848 35.4443L90.6354 35.3149C90.2692 35.2417 89.9965 35.1147 89.8175 34.9341C89.6401 34.7534 89.5514 34.5231 89.5514 34.2432C89.5514 34.0023 89.6165 33.7948 89.7467 33.6206C89.8785 33.4465 90.0633 33.3122 90.3009 33.2178C90.5402 33.1234 90.8201 33.0762 91.1407 33.0762C91.6144 33.0762 91.9871 33.1763 92.2589 33.3765C92.5323 33.5767 92.6919 33.8493 92.7374 34.1943ZM94.5178 34.707V36.875H93.4778V31.875H94.4885V33.7866H94.5325C94.6171 33.5653 94.7538 33.3919 94.9426 33.2666C95.1314 33.1396 95.3682 33.0762 95.6531 33.0762C95.9135 33.0762 96.1405 33.1331 96.3342 33.2471C96.5295 33.3594 96.6809 33.5213 96.7883 33.7329C96.8974 33.9429 96.9511 34.1943 96.9495 34.4873V36.875H95.9094V34.6729C95.9111 34.4417 95.8525 34.2619 95.7336 34.1333C95.6165 34.0047 95.4521 33.9404 95.2405 33.9404C95.0989 33.9404 94.9736 33.9705 94.8645 34.0308C94.7571 34.091 94.6724 34.1789 94.6106 34.2944C94.5504 34.4084 94.5194 34.5459 94.5178 34.707ZM97.7875 36.875V31.875H98.8275V33.7549H98.8593C98.9048 33.654 98.9707 33.5514 99.057 33.4473C99.1449 33.3415 99.2588 33.2536 99.3988 33.1836C99.5404 33.112 99.7162 33.0762 99.9261 33.0762C100.2 33.0762 100.452 33.1478 100.683 33.291C100.914 33.4326 101.099 33.6466 101.237 33.9331C101.376 34.2179 101.445 34.5752 101.445 35.0049C101.445 35.4232 101.377 35.7764 101.242 36.0645C101.109 36.3509 100.926 36.5682 100.695 36.7163C100.466 36.8628 100.209 36.936 99.9237 36.936C99.7219 36.936 99.5502 36.9027 99.4086 36.8359C99.2686 36.7692 99.1538 36.6854 99.0643 36.5845C98.9748 36.4819 98.9065 36.3786 98.8593 36.2744H98.8129V36.875H97.7875ZM98.8055 35C98.8055 35.223 98.8365 35.4175 98.8983 35.5835C98.9602 35.7495 99.0497 35.8789 99.1669 35.9717C99.2841 36.0628 99.4265 36.1084 99.5941 36.1084C99.7634 36.1084 99.9066 36.062 100.024 35.9692C100.141 35.8748 100.23 35.7446 100.29 35.5786C100.352 35.411 100.383 35.2181 100.383 35C100.383 34.7835 100.353 34.5931 100.292 34.4287C100.232 34.2643 100.143 34.1357 100.026 34.043C99.9091 33.9502 99.765 33.9038 99.5941 33.9038C99.4248 33.9038 99.2816 33.9486 99.1644 34.0381C99.0489 34.1276 98.9602 34.2546 98.8983 34.4189C98.8365 34.5833 98.8055 34.777 98.8055 35ZM103.833 36.9482C103.454 36.9482 103.126 36.8677 102.849 36.7065C102.574 36.5438 102.362 36.3175 102.212 36.0278C102.062 35.7365 101.987 35.3988 101.987 35.0146C101.987 34.6273 102.062 34.2887 102.212 33.999C102.362 33.7077 102.574 33.4814 102.849 33.3203C103.126 33.1576 103.454 33.0762 103.833 33.0762C104.212 33.0762 104.539 33.1576 104.814 33.3203C105.091 33.4814 105.304 33.7077 105.454 33.999C105.604 34.2887 105.679 34.6273 105.679 35.0146C105.679 35.3988 105.604 35.7365 105.454 36.0278C105.304 36.3175 105.091 36.5438 104.814 36.7065C104.539 36.8677 104.212 36.9482 103.833 36.9482ZM103.838 36.1426C104.01 36.1426 104.154 36.0937 104.27 35.9961C104.386 35.8968 104.473 35.7617 104.531 35.5908C104.591 35.4199 104.622 35.2254 104.622 35.0073C104.622 34.7892 104.591 34.5947 104.531 34.4238C104.473 34.2529 104.386 34.1178 104.27 34.0186C104.154 33.9193 104.01 33.8696 103.838 33.8696C103.664 33.8696 103.517 33.9193 103.398 34.0186C103.281 34.1178 103.193 34.2529 103.132 34.4238C103.074 34.5947 103.044 34.7892 103.044 35.0073C103.044 35.2254 103.074 35.4199 103.132 35.5908C103.193 35.7617 103.281 35.8968 103.398 35.9961C103.517 36.0937 103.664 36.1426 103.838 36.1426ZM107.424 36.9458C107.185 36.9458 106.972 36.9043 106.785 36.8213C106.597 36.7367 106.449 36.6121 106.34 36.4478C106.233 36.2817 106.179 36.075 106.179 35.8276C106.179 35.6193 106.217 35.4443 106.294 35.3027C106.37 35.1611 106.475 35.0472 106.606 34.9609C106.738 34.8747 106.888 34.8096 107.056 34.7656C107.225 34.7217 107.402 34.6908 107.588 34.6729C107.806 34.6501 107.982 34.6289 108.115 34.6094C108.249 34.5882 108.346 34.5573 108.406 34.5166C108.466 34.4759 108.496 34.4157 108.496 34.3359V34.3213C108.496 34.1667 108.447 34.047 108.35 33.9624C108.254 33.8778 108.117 33.8354 107.939 33.8354C107.752 33.8354 107.603 33.877 107.493 33.96C107.382 34.0413 107.309 34.1439 107.273 34.2676L106.311 34.1895C106.36 33.9616 106.456 33.7646 106.599 33.5986C106.742 33.431 106.927 33.3024 107.153 33.2129C107.381 33.1217 107.645 33.0762 107.944 33.0762C108.153 33.0762 108.352 33.1006 108.542 33.1494C108.735 33.1982 108.905 33.2739 109.053 33.3765C109.202 33.479 109.32 33.6108 109.407 33.772C109.493 33.9315 109.536 34.1227 109.536 34.3457V36.875H108.55V36.355H108.521C108.46 36.4722 108.38 36.5755 108.279 36.665C108.178 36.7529 108.057 36.8221 107.915 36.8726C107.773 36.9214 107.61 36.9458 107.424 36.9458ZM107.722 36.228C107.875 36.228 108.01 36.1979 108.127 36.1377C108.245 36.0758 108.337 35.9928 108.403 35.8887C108.47 35.7845 108.503 35.6665 108.503 35.5347V35.1367C108.471 35.1579 108.426 35.1774 108.369 35.1953C108.314 35.2116 108.251 35.2271 108.181 35.2417C108.111 35.2547 108.041 35.2669 107.971 35.2783C107.901 35.2881 107.838 35.297 107.781 35.3052C107.659 35.3231 107.552 35.3516 107.461 35.3906C107.37 35.4297 107.299 35.4826 107.249 35.5493C107.198 35.6144 107.173 35.6958 107.173 35.7935C107.173 35.9351 107.224 36.0433 107.327 36.1182C107.431 36.1914 107.563 36.228 107.722 36.228ZM110.343 36.875V33.125H111.351V33.7793H111.39C111.459 33.5465 111.573 33.3708 111.735 33.252C111.896 33.1315 112.081 33.0713 112.291 33.0713C112.343 33.0713 112.399 33.0745 112.46 33.0811C112.52 33.0876 112.573 33.0965 112.618 33.1079V34.0308C112.57 34.0161 112.502 34.0031 112.416 33.9917C112.33 33.9803 112.251 33.9746 112.179 33.9746C112.026 33.9746 111.889 34.008 111.769 34.0747C111.65 34.1398 111.556 34.231 111.486 34.3481C111.417 34.4653 111.383 34.6004 111.383 34.7534V36.875H110.343ZM114.363 36.936C114.079 36.936 113.821 36.8628 113.589 36.7163C113.36 36.5682 113.178 36.3509 113.043 36.0645C112.909 35.7764 112.842 35.4232 112.842 35.0049C112.842 34.5752 112.912 34.2179 113.05 33.9331C113.188 33.6466 113.372 33.4326 113.602 33.291C113.833 33.1478 114.086 33.0762 114.361 33.0762C114.571 33.0762 114.746 33.112 114.886 33.1836C115.027 33.2536 115.141 33.3415 115.228 33.4473C115.316 33.5514 115.382 33.654 115.428 33.7549H115.46V31.875H116.497V36.875H115.472V36.2744H115.428C115.379 36.3786 115.31 36.4819 115.22 36.5845C115.132 36.6854 115.018 36.7692 114.876 36.8359C114.736 36.9027 114.565 36.936 114.363 36.936ZM114.693 36.1084C114.861 36.1084 115.002 36.0628 115.118 35.9717C115.235 35.8789 115.325 35.7495 115.386 35.5835C115.45 35.4175 115.482 35.223 115.482 35C115.482 34.777 115.451 34.5833 115.389 34.4189C115.327 34.2546 115.237 34.1276 115.12 34.0381C115.003 33.9486 114.861 33.9038 114.693 33.9038C114.522 33.9038 114.378 33.9502 114.261 34.043C114.144 34.1357 114.055 34.2643 113.995 34.4287C113.935 34.5931 113.904 34.7835 113.904 35C113.904 35.2181 113.935 35.411 113.995 35.5786C114.057 35.7446 114.145 35.8748 114.261 35.9692C114.378 36.062 114.522 36.1084 114.693 36.1084Z", - ), - h.Attribute("fill", "#F7F7F7"), - ]), - h.rect([ - h.Attribute("x", "138.125"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "161.25"), - h.Attribute("y", "29.875"), - h.Attribute("width", "20"), - h.Attribute("height", "6.875"), - h.Attribute("rx", "1.875"), - h.Attribute("fill", "#373A41"), - ]), - h.mask( - [ - h.Attribute("id", "path-22-inside-2_8021_27290"), - h.Attribute("fill", "white"), - ], - [h.path([h.Attribute("d", "M12.5 23.625H57.5V143H12.5V23.625Z")])], - ), - h.path([ - h.Attribute("d", "M57.1875 23.625V143H57.8125V23.625H57.1875Z"), - h.Attribute("fill", "#22262F"), - h.Attribute("mask", "url(#path-22-inside-2_8021_27290)"), - ]), - h.rect([ - h.Attribute("x", "25.625"), - h.Attribute("y", "30.5"), - h.Attribute("width", "13.75"), - h.Attribute("height", "3.75"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "29.875"), - h.Attribute("width", "5"), - h.Attribute("height", "5"), - h.Attribute("rx", "2.5"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "42.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#373A41"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "51.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "59.875"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "68.625"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "77.375"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - h.rect([ - h.Attribute("x", "18.75"), - h.Attribute("y", "86.125"), - h.Attribute("width", "27.5"), - h.Attribute("height", "5"), - h.Attribute("rx", "1.25"), - h.Attribute("fill", "#22262F"), - ]), - ], - ), - h.rect([ - h.Attribute("x", "12.8125"), - h.Attribute("y", "13.3125"), - h.Attribute("width", "174.375"), - h.Attribute("height", "129.375"), - h.Attribute("rx", "4.6875"), - h.Attribute("stroke", "#373A41"), - h.Attribute("stroke-width", "0.625"), - ]), - ], - ), - h.path([ - h.Attribute( - "d", - "M0.5 10.5C0.5 5.25329 4.75329 1 10 1H190C195.247 1 199.5 5.2533 199.5 10.5V122.5C199.5 127.747 195.247 132 190 132H10C4.7533 132 0.5 127.747 0.5 122.5V10.5Z", - ), - h.Attribute("stroke", "#373A41"), - ]), - ], - ), - h.defs( - [], - [ - h.filter( - [ - h.Attribute("id", "filter0_i_8021_27290"), - h.Attribute("x", "16.875"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.92549 0 0 0 0 0.427451 0 0 0 0 0.384314 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter1_i_8021_27290"), - h.Attribute("x", "23.125"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.960784 0 0 0 0 0.768627 0 0 0 0 0.317647 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.filter( - [ - h.Attribute("id", "filter2_i_8021_27290"), - h.Attribute("x", "29.375"), - h.Attribute("y", "16.75"), - h.Attribute("width", "3.75"), - h.Attribute("height", "3.75"), - h.Attribute("filterUnits", "userSpaceOnUse"), - h.Attribute("color-interpolation-filters", "sRGB"), - ], - [ - h.feFlood([ - h.Attribute("flood-opacity", "0"), - h.Attribute("result", "BackgroundImageFix"), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in", "SourceGraphic"), - h.Attribute("in2", "BackgroundImageFix"), - h.Attribute("result", "shape"), - ]), - h.feColorMatrix([ - h.Attribute("in", "SourceAlpha"), - h.Attribute("type", "matrix"), - h.Attribute("values", "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"), - h.Attribute("result", "hardAlpha"), - ]), - h.feOffset([]), - h.feGaussianBlur([h.Attribute("stdDeviation", "0.9375")]), - h.feComposite([ - h.Attribute("in2", "hardAlpha"), - h.Attribute("operator", "arithmetic"), - h.Attribute("k2", "-1"), - h.Attribute("k3", "1"), - ]), - h.feColorMatrix([ - h.Attribute("type", "matrix"), - h.Attribute( - "values", - "0 0 0 0 0.407843 0 0 0 0 0.8 0 0 0 0 0.345098 0 0 0 1 0", - ), - ]), - h.feBlend([ - h.Attribute("mode", "normal"), - h.Attribute("in2", "shape"), - h.Attribute("result", "effect1_innerShadow_8021_27290"), - ]), - ], - ), - h.linearGradient( - [ - h.Attribute("id", "paint0_linear_8021_27290"), - h.Attribute("x1", "52.5"), - h.Attribute("y1", "0"), - h.Attribute("x2", "52.5"), - h.Attribute("y2", "32.5"), - h.Attribute("gradientUnits", "userSpaceOnUse"), - ], - [h.stop([]), h.stop([h.Attribute("offset", "1"), h.Attribute("stop-opacity", "0")])], - ), - h.clipPath( - [h.Attribute("id", "clip0_8021_27290")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip1_8021_27290")], - [ - h.path([ - h.Attribute( - "d", - "M0 10.5C0 4.97715 4.47715 0.5 10 0.5H190C195.523 0.5 200 4.97715 200 10.5V122.5C200 128.023 195.523 132.5 190 132.5H10C4.47716 132.5 0 128.023 0 122.5V10.5Z", - ), - h.Attribute("fill", "white"), - ]), - ], - ), - h.clipPath( - [h.Attribute("id", "clip2_8021_27290")], - [ - h.rect([ - h.Attribute("x", "12.5"), - h.Attribute("y", "13"), - h.Attribute("width", "175"), - h.Attribute("height", "130"), - h.Attribute("rx", "5"), - h.Attribute("fill", "white"), - ]), - ], - ), - ], - ), - ], - ) From a35625ec8b8ced645d44f851300e2d833691b017 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:20:41 +0200 Subject: [PATCH 26/52] refactor(web): typed failures and retryable lookups on join, invite and create-workspace Join and invite lookups are Loading | Found/Loaded | NotFound | Failed{reason}: a network error no longer renders as an invalid link, and Failed offers Try again. Accept/join/create build their reasons with catchTags + catchCommon (no hand-read cause.reasons or _tag strings); copy comes from failureMessage. Invite and create-workspace hold a Submission union instead of isSubmitting/error flags. Join reuses the auth frame and shared initials helper, and its Go to Home is a button-styled link instead of a button inside an anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/page/create-workspace.ts | 98 +++++---- apps/web-foldkit/src/page/invite/command.ts | 75 +++---- apps/web-foldkit/src/page/invite/message.ts | 8 +- apps/web-foldkit/src/page/invite/model.ts | 25 ++- .../web-foldkit/src/page/invite/scene.test.ts | 42 ++-- apps/web-foldkit/src/page/invite/update.ts | 41 ++-- apps/web-foldkit/src/page/invite/view.ts | 56 ++--- apps/web-foldkit/src/page/join/command.ts | 64 +++--- apps/web-foldkit/src/page/join/message.ts | 10 +- apps/web-foldkit/src/page/join/model.ts | 18 +- apps/web-foldkit/src/page/join/scene.test.ts | 30 ++- apps/web-foldkit/src/page/join/story.test.ts | 19 +- apps/web-foldkit/src/page/join/update.ts | 39 +++- apps/web-foldkit/src/page/join/view.ts | 193 +++++++----------- .../setup-organization/scene.test.ts | 3 +- 15 files changed, 399 insertions(+), 322 deletions(-) diff --git a/apps/web-foldkit/src/page/create-workspace.ts b/apps/web-foldkit/src/page/create-workspace.ts index 13c1a3bf6..b8744b88e 100644 --- a/apps/web-foldkit/src/page/create-workspace.ts +++ b/apps/web-foldkit/src/page/create-workspace.ts @@ -1,9 +1,13 @@ -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { load } from "foldkit/navigation" +import { defineTaggedUnion } from "foldkit/schema" import { modifyFields } from "foldkit/struct" +import { CommonReason, failureMessage } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" +import { AppRoute, hrefOf } from "../route" import { HazelRpc } from "../rpc" import { button } from "../ui/button" import { loader } from "../ui/loader" @@ -16,14 +20,28 @@ import type { PageReturn } from "./contract" * reloads `/`, which carries on to onboarding or the new workspace. */ +/** Why `organization.create` failed, beyond the shared reasons. */ +export const CreateWorkspaceFailure = Schema.Union([Schema.Literals(["SlugTaken"]), CommonReason]) +export type CreateWorkspaceFailure = typeof CreateWorkspaceFailure.Type + +/** + * `Invalid`: a submit was blocked by the fields, so their errors show from then on. `Submitting` + * stays until the browser loads the new workspace. + */ +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Invalid: {}, + Submitting: {}, + Failed: { reason: CreateWorkspaceFailure }, +}) +export type Submission = typeof Submission.Type + export const Model = Schema.Struct({ name: Schema.String, slug: Schema.String, /** Once the slug is edited by hand it stops following the name. */ isSlugEdited: Schema.Boolean, - hasSubmitted: Schema.Boolean, - isSubmitting: Schema.Boolean, - error: Schema.NullOr(Schema.String), + submission: Submission, }) export type Model = typeof Model.Type @@ -32,7 +50,7 @@ export const Message = defineMessageUnion({ ChangedSlug: { value: Schema.String }, SubmittedWorkspace: {}, LeftForWorkspace: {}, - FailedCreateWorkspace: { error: Schema.String }, + FailedCreateWorkspace: { reason: CreateWorkspaceFailure }, }) export type Message = typeof Message.Type @@ -40,9 +58,7 @@ export const init = (): Model => ({ name: "", slug: "", isSlugEdited: false, - hasSubmitted: false, - isSubmitting: false, - error: null, + submission: Submission.NotSubmitted(), }) /** `Acme Corp!` → `acme-corp`. */ @@ -69,27 +85,27 @@ export const CreateWorkspace = Command.define("CreateWorkspace", { args: { name: Schema.String, slug: Schema.String }, messages: [Message.LeftForWorkspace, Message.FailedCreateWorkspace], execute: ({ name, slug }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("organization.create", { name, slug, logoUrl: null, settings: null, isPublic: false }), - ) - if (Exit.isSuccess(exit)) { - // A full load, so user.me and the membership queries start from the new workspace. - yield* load("/") - return Message.LeftForWorkspace() - } - const isSlugTaken = exit.cause.reasons.some( - (reason) => "error" in reason && reason.error._tag === "OrganizationSlugAlreadyExistsError", - ) - return Message.FailedCreateWorkspace({ - error: isSlugTaken - ? "That workspace URL is taken. Pick another." - : "Couldn't create the workspace. Please try again.", - }) - }), + HazelRpc.use((client) => + client("organization.create", { name, slug, logoUrl: null, settings: null, isPublic: false }), + ).pipe( + // A full load, so user.me and the membership queries start from the new workspace. + Effect.andThen(load(hrefOf(AppRoute.Root()))), + Effect.as(Message.LeftForWorkspace()), + Effect.catchTags({ + OrganizationSlugAlreadyExistsError: () => + Effect.succeed(Message.FailedCreateWorkspace({ reason: "SlugTaken" })), + }), + catchCommon((reason) => Message.FailedCreateWorkspace({ reason })), + ), }) +const failureText = (reason: CreateWorkspaceFailure): string => { + const message = failureMessage(reason, { + SlugTaken: { title: "That workspace URL is taken", description: "Pick another." }, + }) + return message.description === undefined ? message.title : `${message.title}. ${message.description}` +} + type Return = PageReturn<Model, Message> export const update = (model: Model, message: Message): Return => @@ -104,17 +120,17 @@ export const update = (model: Model, message: Message): Return => model: modifyFields(model, { slug: () => value.toLowerCase(), isSlugEdited: () => true }), }), SubmittedWorkspace: () => { - const submitted = modifyFields(model, { hasSubmitted: () => true, error: () => null }) - if (model.isSubmitting || nameError(model) !== null || slugError(model) !== null) - return { model: submitted } + if (model.submission._tag === "Submitting") return { model } + if (nameError(model) !== null || slugError(model) !== null) + return { model: modifyFields(model, { submission: () => Submission.Invalid() }) } return { - model: modifyFields(submitted, { isSubmitting: () => true }), + model: modifyFields(model, { submission: () => Submission.Submitting() }), commands: [CreateWorkspace({ name: model.name.trim(), slug: model.slug })], } }, LeftForWorkspace: () => ({ model }), - FailedCreateWorkspace: ({ error }) => ({ - model: modifyFields(model, { isSubmitting: () => false, error: () => error }), + FailedCreateWorkspace: ({ reason }) => ({ + model: modifyFields(model, { submission: () => Submission.Failed({ reason }) }), }), }) @@ -123,8 +139,10 @@ export const view = <ParentMessage>( model: Model, toParent: (message: Message) => ParentMessage, ): Html => { - const nameInvalid = model.hasSubmitted ? nameError(model) : null - const slugInvalid = model.hasSubmitted ? slugError(model) : null + const showsErrors = model.submission._tag !== "NotSubmitted" + const isSubmitting = model.submission._tag === "Submitting" + const nameInvalid = showsErrors ? nameError(model) : null + const slugInvalid = showsErrors ? slugError(model) : null return h.form( [h.Class("flex w-full flex-col gap-4"), h.OnSubmit(toParent(Message.SubmittedWorkspace()))], [ @@ -159,18 +177,18 @@ export const view = <ParentMessage>( field.fieldError([slugInvalid ?? ""]), ], ), - ...(model.error === null - ? [] - : [h.p([h.Class("text-danger text-sm"), h.Role("alert")], [model.error])]), + ...(model.submission._tag === "Failed" + ? [h.p([h.Class("text-danger text-sm"), h.Role("alert")], [failureText(model.submission.reason)])] + : []), button( h, { intent: "primary", className: "mt-2 w-full", - isDisabled: model.isSubmitting, + isDisabled: isSubmitting, onPress: toParent(Message.SubmittedWorkspace()), }, - model.isSubmitting + isSubmitting ? [loader(h, { className: "size-4" }), "Creating workspace..."] : ["Create workspace"], ), diff --git a/apps/web-foldkit/src/page/invite/command.ts b/apps/web-foldkit/src/page/invite/command.ts index 1bfb66eac..060a03c9b 100644 --- a/apps/web-foldkit/src/page/invite/command.ts +++ b/apps/web-foldkit/src/page/invite/command.ts @@ -1,72 +1,57 @@ -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { load } from "foldkit/navigation" +import { catchCommon } from "../../data/actions" import { AppRoute, hrefOf, signInHref } from "../../route" import { HazelRpc } from "../../rpc" import { Message } from "./message" -const tagsOf = (cause: { readonly reasons: ReadonlyArray<object> }) => - cause.reasons.flatMap((reason) => - "error" in reason && - typeof reason.error === "object" && - reason.error !== null && - "_tag" in reason.error - ? [String(reason.error._tag)] - : [], - ) - export const FetchPreview = Command.define("FetchPreview", { args: { token: Schema.String }, messages: [Message.SucceededFetchPreview, Message.FailedFetchPreview], execute: ({ token }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("invitation.preview", { token })) - if (Exit.isSuccess(exit)) - return Message.SucceededFetchPreview({ + HazelRpc.use((client) => client("invitation.preview", { token })).pipe( + Effect.map((preview) => + Message.SucceededFetchPreview({ preview: { - organizationName: exit.value.organizationName, - organizationLogoUrl: exit.value.organizationLogoUrl, - email: exit.value.email, - status: exit.value.status, + organizationName: preview.organizationName, + organizationLogoUrl: preview.organizationLogoUrl, + email: preview.email, + status: preview.status, }, - }) - const tags = tagsOf(exit.cause) - // A malformed token fails the payload schema, which is as good as not found. - return Message.FailedFetchPreview({ - isNotFound: tags.includes("InvitationNotFoundError") || tags.includes("SchemaError"), - }) - }), + }), + ), + Effect.catchTags({ + InvitationNotFoundError: () => Effect.succeed(Message.FailedFetchPreview({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedFetchPreview({ reason })), + ), }) /** Sign in, then come back to this link. */ -export const RedirectToSignIn = Command.define("RedirectToInviteSignIn", { +export const RedirectToInviteSignIn = Command.define("RedirectToInviteSignIn", { args: { token: Schema.String }, messages: [Message.LeftPage], execute: ({ token }) => load(signInHref(hrefOf(AppRoute.Invite({ token })))).pipe(Effect.as(Message.LeftPage())), }) -const acceptFailure = (tags: ReadonlyArray<string>) => - tags.includes("InvitationEmailMismatchError") - ? "This invitation was sent to a different email address." - : tags.includes("InvitationUnavailableError") || tags.includes("InvitationNotFoundError") - ? "This invitation is no longer valid. Ask for a new one." - : tags.includes("OrganizationNotFoundError") - ? "This workspace no longer exists." - : "Couldn't accept the invitation. Please try again." - /** Joins, then a full load of the workspace so its data starts syncing. */ export const AcceptInvitation = Command.define("AcceptInvitation", { args: { token: Schema.String }, messages: [Message.LeftPage, Message.FailedAccept], execute: ({ token }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("invitation.accept", { token })) - if (Exit.isFailure(exit)) - return Message.FailedAccept({ error: acceptFailure(tagsOf(exit.cause)) }) - yield* load(exit.value.slug ? hrefOf(AppRoute.OrgHome({ orgSlug: exit.value.slug })) : "/") - return Message.LeftPage() - }), + HazelRpc.use((client) => client("invitation.accept", { token })).pipe( + Effect.flatMap((accepted) => + load(accepted.slug ? hrefOf(AppRoute.OrgHome({ orgSlug: accepted.slug })) : hrefOf(AppRoute.Root())), + ), + Effect.as(Message.LeftPage()), + Effect.catchTags({ + InvitationEmailMismatchError: () => Effect.succeed(Message.FailedAccept({ reason: "EmailMismatch" })), + InvitationUnavailableError: () => Effect.succeed(Message.FailedAccept({ reason: "NoLongerValid" })), + InvitationNotFoundError: () => Effect.succeed(Message.FailedAccept({ reason: "NoLongerValid" })), + OrganizationNotFoundError: () => Effect.succeed(Message.FailedAccept({ reason: "WorkspaceGone" })), + }), + catchCommon((reason) => Message.FailedAccept({ reason })), + ), }) diff --git a/apps/web-foldkit/src/page/invite/message.ts b/apps/web-foldkit/src/page/invite/message.ts index 97c991d18..74332d13e 100644 --- a/apps/web-foldkit/src/page/invite/message.ts +++ b/apps/web-foldkit/src/page/invite/message.ts @@ -1,15 +1,15 @@ -import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { Preview } from "./model" +import { AcceptFailure, Preview, PreviewFailure } from "./model" export const Message = defineMessageUnion({ SucceededFetchPreview: { preview: Preview }, - FailedFetchPreview: { isNotFound: Schema.Boolean }, + FailedFetchPreview: { reason: PreviewFailure }, + ClickedRetryPreview: {}, ClickedSignIn: {}, ClickedAccept: {}, ClickedSignOut: {}, /** The browser is loading the workspace or sign-in. */ LeftPage: {}, - FailedAccept: { error: Schema.String }, + FailedAccept: { reason: AcceptFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/invite/model.ts b/apps/web-foldkit/src/page/invite/model.ts index 260584789..9f355ae30 100644 --- a/apps/web-foldkit/src/page/invite/model.ts +++ b/apps/web-foldkit/src/page/invite/model.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" export const Preview = Schema.Struct({ organizationName: Schema.String, @@ -9,19 +10,37 @@ export const Preview = Schema.Struct({ }) export type Preview = typeof Preview.Type +/** Why `invitation.preview` failed; a malformed token reads as `NotFound`. */ +export const PreviewFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type PreviewFailure = typeof PreviewFailure.Type + +/** Why `invitation.accept` failed, beyond the shared reasons. */ +export const AcceptFailure = Schema.Union([ + Schema.Literals(["EmailMismatch", "NoLongerValid", "WorkspaceGone"]), + CommonReason, +]) +export type AcceptFailure = typeof AcceptFailure.Type + /** `invitation.preview`: the invite link's organization and status. */ export const Lookup = defineTaggedUnion({ Loading: {}, Loaded: { preview: Preview }, NotFound: {}, - Failed: {}, + Failed: { reason: CommonReason }, }) export type Lookup = typeof Lookup.Type +/** Accepting stays in flight until the browser loads the workspace. */ +export const Acceptance = defineTaggedUnion({ + Idle: {}, + Accepting: {}, + Failed: { reason: AcceptFailure }, +}) +export type Acceptance = typeof Acceptance.Type + export const Model = Schema.Struct({ token: Schema.String, lookup: Lookup, - isAccepting: Schema.Boolean, - error: Schema.NullOr(Schema.String), + acceptance: Acceptance, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/invite/scene.test.ts b/apps/web-foldkit/src/page/invite/scene.test.ts index 286a13cca..1f7bd18e5 100644 --- a/apps/web-foldkit/src/page/invite/scene.test.ts +++ b/apps/web-foldkit/src/page/invite/scene.test.ts @@ -3,10 +3,10 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { currentUser, makeShared, pageScene } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" -import { AcceptInvitation, RedirectToSignIn } from "./command" +import { AcceptInvitation, FetchPreview, RedirectToInviteSignIn } from "./command" import { Message } from "./message" -import { Lookup, type Model, type Preview } from "./model" -import { update } from "./update" +import { Acceptance, Lookup, type Model, type Preview } from "./model" +import { init, update } from "./update" import { view } from "./view" /** The invitation link: sign in, accept with the invited account, or explain why it can't be used. */ @@ -21,8 +21,7 @@ const preview = (overrides: Partial<Preview> = {}): Preview => ({ const loaded = (overrides: Partial<Preview> = {}): Model => ({ token: "tok_abcdefghijklmnop", lookup: Lookup.Loaded({ preview: preview(overrides) }), - isAccepting: false, - error: null, + acceptance: Acceptance.Idle(), }) const signedOut = makeShared({ auth: "SignedOut", currentUser: null, organization: null, member: null }) @@ -33,8 +32,8 @@ describe("invite page", () => { Scene.given(loaded()), Scene.expect(Scene.role("heading", { name: "Join Hazel Labs" })).toExist(), Scene.click(Scene.role("button", { name: "Sign in to accept" })), - Scene.Command.expectExact(RedirectToSignIn({ token: "tok_abcdefghijklmnop" })), - Scene.Command.resolve(RedirectToSignIn, Message.LeftPage()), + Scene.Command.expectExact(RedirectToInviteSignIn({ token: "tok_abcdefghijklmnop" })), + Scene.Command.resolve(RedirectToInviteSignIn, Message.LeftPage()), ) }) @@ -44,14 +43,12 @@ describe("invite page", () => { Scene.given(loaded()), Scene.click(Scene.role("button", { name: "Accept invitation" })), Scene.Command.expectExact(AcceptInvitation({ token: "tok_abcdefghijklmnop" })), - Scene.Command.resolve( - AcceptInvitation, - Message.FailedAccept({ error: "This invitation is no longer valid. Ask for a new one." }), - ), + Scene.expect(Scene.role("button", { name: "Joining..." })).toBeDisabled(), + Scene.Command.resolve(AcceptInvitation, Message.FailedAccept({ reason: "NoLongerValid" })), Scene.expect(Scene.role("alert")).toHaveText( "This invitation is no longer valid. Ask for a new one.", ), - Scene.expect(Scene.role("button", { name: "Accept invitation" })).toExist(), + Scene.expect(Scene.role("button", { name: "Accept invitation" })).toBeEnabled(), ) }) @@ -79,4 +76,25 @@ describe("invite page", () => { Scene.expect(Scene.role("heading", { name: "Invitation not found" })).toExist(), ) }) + + test("a failed lookup is not a missing invitation: it says so and retries", () => { + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given<Model>({ ...loaded(), lookup: Lookup.Failed({ reason: "Offline" }) }), + Scene.expect(Scene.role("heading", { name: "Couldn't load the invitation" })).toExist(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(FetchPreview({ token: "tok_abcdefghijklmnop" })), + Scene.Command.resolve(FetchPreview, Message.SucceededFetchPreview({ preview: preview() })), + Scene.expect(Scene.role("heading", { name: "Join Hazel Labs" })).toExist(), + ) + }) + + test("a malformed token is not found without asking the server", () => { + const started = init({ _tag: "Invite", token: "short" }) + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(started.model), + Scene.expect(Scene.role("heading", { name: "Invitation not found" })).toExist(), + ) + }) }) diff --git a/apps/web-foldkit/src/page/invite/update.ts b/apps/web-foldkit/src/page/invite/update.ts index 774de8d09..8fbb1ada0 100644 --- a/apps/web-foldkit/src/page/invite/update.ts +++ b/apps/web-foldkit/src/page/invite/update.ts @@ -2,36 +2,51 @@ import { modifyFields } from "foldkit/struct" import type { RouteOf } from "../../route" import type { PageReturn } from "../contract" import { PageOutMessage } from "../out-message" -import { AcceptInvitation, FetchPreview, RedirectToSignIn } from "./command" +import { AcceptInvitation, FetchPreview, RedirectToInviteSignIn } from "./command" import { Message } from "./message" -import { Lookup, type Model } from "./model" +import { Acceptance, Lookup, type Model } from "./model" type Return = PageReturn<Model, Message> -export const init = (route: RouteOf<"Invite">): Return => ({ - model: { token: route.token, lookup: Lookup.Loading(), isAccepting: false, error: null }, - commands: [FetchPreview({ token: route.token })], -}) +/** The contract's token length: anything else would fail the payload schema, so it can't be an invitation. */ +const isWellFormedToken = (token: string) => token.length >= 16 && token.length <= 128 + +export const init = (route: RouteOf<"Invite">): Return => + isWellFormedToken(route.token) + ? { + model: { token: route.token, lookup: Lookup.Loading(), acceptance: Acceptance.Idle() }, + commands: [FetchPreview({ token: route.token })], + } + : { model: { token: route.token, lookup: Lookup.NotFound(), acceptance: Acceptance.Idle() } } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { SucceededFetchPreview: ({ preview }) => ({ model: modifyFields(model, { lookup: () => Lookup.Loaded({ preview }) }), }), - FailedFetchPreview: ({ isNotFound }) => ({ - model: modifyFields(model, { lookup: () => (isNotFound ? Lookup.NotFound() : Lookup.Failed()) }), + FailedFetchPreview: ({ reason }) => ({ + model: modifyFields(model, { + lookup: () => (reason === "NotFound" ? Lookup.NotFound() : Lookup.Failed({ reason })), + }), }), - ClickedSignIn: () => ({ model, commands: [RedirectToSignIn({ token: model.token })] }), + ClickedRetryPreview: () => + model.lookup._tag !== "Failed" + ? { model } + : { + model: modifyFields(model, { lookup: () => Lookup.Loading() }), + commands: [FetchPreview({ token: model.token })], + }, + ClickedSignIn: () => ({ model, commands: [RedirectToInviteSignIn({ token: model.token })] }), ClickedAccept: () => - model.isAccepting + model.acceptance._tag === "Accepting" ? { model } : { - model: modifyFields(model, { isAccepting: () => true, error: () => null }), + model: modifyFields(model, { acceptance: () => Acceptance.Accepting() }), commands: [AcceptInvitation({ token: model.token })], }, ClickedSignOut: () => ({ model, outMessage: PageOutMessage.RequestedSignOut() }), LeftPage: () => ({ model }), - FailedAccept: ({ error }) => ({ - model: modifyFields(model, { isAccepting: () => false, error: () => error }), + FailedAccept: ({ reason }) => ({ + model: modifyFields(model, { acceptance: () => Acceptance.Failed({ reason }) }), }), }) diff --git a/apps/web-foldkit/src/page/invite/view.ts b/apps/web-foldkit/src/page/invite/view.ts index 89ef013b0..38776a878 100644 --- a/apps/web-foldkit/src/page/invite/view.ts +++ b/apps/web-foldkit/src/page/invite/view.ts @@ -3,20 +3,16 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { avatar } from "../../ui/avatar" import { button } from "../../ui/button" import { loader } from "../../ui/loader" +import { commonMessage, failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { AppRoute, hrefOf } from "../../route" import { authError, authFrame, authHeading } from "../auth/layout" -import type { PageViewInputs, Shared } from "../contract" +import { isSessionLoading, type PageViewInputs, type Shared } from "../contract" +import { initialsOf } from "../initials" import { Message } from "./message" -import type { Model, Preview } from "./model" - -const initialsOf = (name: string) => - name - .split(" ") - .map((word) => word.charAt(0).toUpperCase()) - .slice(0, 2) - .join("") +import { type AcceptFailure, Lookup, type Model, type Preview } from "./model" const homeLink = (h: HtmlBuilder<Message>): Html => - h.a([h.Class("text-fg text-sm underline underline-offset-2"), h.Href("/")], ["Go to Hazel"]) + h.a([h.Class("text-fg text-sm underline underline-offset-2"), h.Href(hrefOf(AppRoute.Root()))], ["Go to Hazel"]) const unavailable = (h: HtmlBuilder<Message>, title: string, description: string): ReadonlyArray<Html> => [ authHeading(h, title, description), @@ -30,8 +26,12 @@ const statusMessage = (status: Preview["status"]) => ? "This invitation was withdrawn. Ask for a new one." : "This invitation has expired. Ask for a new one." -const isLoadingSession = (shared: Shared) => - shared.auth === "Loading" || (shared.auth === "SignedIn" && shared.currentUser === null) +const acceptFailureMessage = (reason: AcceptFailure): UserErrorMessage => + failureMessage(reason, { + EmailMismatch: { title: "This invitation was sent to a different email address" }, + NoLongerValid: { title: "This invitation is no longer valid", description: "Ask for a new one." }, + WorkspaceGone: { title: "This workspace no longer exists" }, + }) const actions = ( h: HtmlBuilder<Message>, @@ -58,16 +58,17 @@ const actions = ( "Sign in with another account", ]), ] + const isAccepting = model.acceptance._tag === "Accepting" return [ button( h, { intent: "primary", className: "w-full", - isDisabled: model.isAccepting, + isDisabled: isAccepting, onPress: Message.ClickedAccept(), }, - model.isAccepting ? [loader(h, { className: "size-4" }), "Joining..."] : ["Accept invitation"], + isAccepting ? [loader(h, { className: "size-4" }), "Joining..."] : ["Accept invitation"], ), ] } @@ -93,27 +94,32 @@ const invitation = ( "You've been invited to this workspace on Hazel.", ), ...actions(h, model, preview, shared), - ...[authError(h, model.error)].filter((html): html is Html => html !== null), + ...(model.acceptance._tag === "Failed" + ? [authError(h, acceptFailureMessage(model.acceptance.reason)) ?? h.empty] + : []), ] export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => authFrame( h, shared.nowMs, - model.lookup._tag === "Loading" || isLoadingSession(shared) + isSessionLoading(shared) ? [loader(h, { className: "size-6" })] - : model.lookup._tag === "Loaded" - ? invitation(h, model, model.lookup.preview, shared) - : model.lookup._tag === "NotFound" - ? unavailable( + : Lookup.match(model.lookup, { + Loading: () => [loader(h, { className: "size-6" })], + Loaded: ({ preview }) => invitation(h, model, preview, shared), + NotFound: () => + unavailable( h, "Invitation not found", "This link doesn't match an invitation. Check that it was copied in full.", - ) - : unavailable( - h, - "Couldn't load the invitation", - "Check your connection and reload the page.", ), + Failed: ({ reason }) => [ + authHeading(h, "Couldn't load the invitation", commonMessage(reason).title), + button(h, { intent: "outline", className: "w-full", onPress: Message.ClickedRetryPreview() }, [ + "Try again", + ]), + ], + }), ), ) diff --git a/apps/web-foldkit/src/page/join/command.ts b/apps/web-foldkit/src/page/join/command.ts index da78f273e..8f4d2c22f 100644 --- a/apps/web-foldkit/src/page/join/command.ts +++ b/apps/web-foldkit/src/page/join/command.ts @@ -1,8 +1,7 @@ -import type { AlreadyMemberError, OrganizationNotFoundError, PublicInviteDisabledError } from "@hazel/domain/rpc" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { load } from "foldkit/navigation" -import { type ErrorHandlers, failureToast } from "../../data/actions" +import { catchCommon } from "../../data/actions" import { signInHref } from "../../route" import { HazelRpc } from "../../rpc" import { Message } from "./message" @@ -11,47 +10,36 @@ export const FetchPublicOrganization = Command.define("FetchPublicOrganization", args: { slug: Schema.String }, messages: [Message.SucceededFetchOrganization, Message.FailedFetchOrganization], execute: ({ slug }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const organization = yield* client("organization.getBySlugPublic", { slug }) - return Message.SucceededFetchOrganization({ - organization: organization - ? { - name: organization.name, - logoUrl: organization.logoUrl ?? null, - memberCount: organization.memberCount, - } - : null, - }) - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedFetchOrganization()))), + HazelRpc.use((client) => client("organization.getBySlugPublic", { slug })).pipe( + Effect.map((organization) => + Message.SucceededFetchOrganization({ + organization: organization + ? { + name: organization.name, + logoUrl: organization.logoUrl ?? null, + memberCount: organization.memberCount, + } + : null, + }), + ), + catchCommon((reason) => Message.FailedFetchOrganization({ reason })), + ), }) -/** The toasts legacy's `exitToastAsync(...).onErrorTag(...)` chain shows. */ -const knownFailures: ErrorHandlers<OrganizationNotFoundError | PublicInviteDisabledError | AlreadyMemberError> = { - OrganizationNotFoundError: { - title: "Organization not found", - description: "This organization may have been deleted.", - }, - PublicInviteDisabledError: { - title: "Public invites disabled", - description: "This organization has disabled public invites.", - }, - AlreadyMemberError: { - title: "Already a member", - description: "You're already a member of this workspace.", - }, -} - export const JoinWorkspace = Command.define("JoinWorkspace", { args: { slug: Schema.String }, messages: [Message.SucceededJoinWorkspace, Message.FailedJoinWorkspace], execute: ({ slug }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("organization.joinViaPublicInvite", { slug })) - if (Exit.isSuccess(exit)) return Message.SucceededJoinWorkspace() - return Message.FailedJoinWorkspace({ toast: failureToast(exit.cause, "friendly", knownFailures) }) - }), + HazelRpc.use((client) => client("organization.joinViaPublicInvite", { slug })).pipe( + Effect.as(Message.SucceededJoinWorkspace()), + Effect.catchTags({ + OrganizationNotFoundError: () => Effect.succeed(Message.FailedJoinWorkspace({ reason: "NotFound" })), + PublicInviteDisabledError: () => + Effect.succeed(Message.FailedJoinWorkspace({ reason: "PublicInvitesDisabled" })), + AlreadyMemberError: () => Effect.succeed(Message.FailedJoinWorkspace({ reason: "AlreadyMember" })), + }), + catchCommon((reason) => Message.FailedJoinWorkspace({ reason })), + ), }) /** `login({ returnTo })`: a full page load of sign-in, coming back here afterwards. */ diff --git a/apps/web-foldkit/src/page/join/message.ts b/apps/web-foldkit/src/page/join/message.ts index d98994bde..774514c10 100644 --- a/apps/web-foldkit/src/page/join/message.ts +++ b/apps/web-foldkit/src/page/join/message.ts @@ -1,15 +1,17 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../overlay/toasts" -import { PublicOrganization } from "./model" +import { CommonReason } from "~/lib/error-messages" +import { JoinFailure, PublicOrganization } from "./model" export const Message = defineMessageUnion({ + /** `null`: no public workspace has this slug. */ SucceededFetchOrganization: { organization: Schema.NullOr(PublicOrganization) }, - FailedFetchOrganization: {}, + FailedFetchOrganization: { reason: CommonReason }, + ClickedRetryLookup: {}, ClickedSignIn: {}, ClickedJoin: {}, SucceededJoinWorkspace: {}, - FailedJoinWorkspace: { toast: ToastRequest }, + FailedJoinWorkspace: { reason: JoinFailure }, CompletedRedirectToSignIn: {}, CompletedEnterAnimation: {}, }) diff --git a/apps/web-foldkit/src/page/join/model.ts b/apps/web-foldkit/src/page/join/model.ts index 5e87f97ed..1ce21af25 100644 --- a/apps/web-foldkit/src/page/join/model.ts +++ b/apps/web-foldkit/src/page/join/model.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" export const PublicOrganization = Schema.Struct({ name: Schema.String, @@ -8,14 +9,25 @@ export const PublicOrganization = Schema.Struct({ }) export type PublicOrganization = typeof PublicOrganization.Type -/** `organization.getBySlugPublic`; `Failed` renders as not found, like legacy's `getOrElse(() => null)`. */ +/** + * `organization.getBySlugPublic`: `NotFound` is an unknown slug or a workspace without public + * invites; `Failed` is the lookup itself failing, which can be retried. + */ export const Lookup = defineTaggedUnion({ Loading: {}, - Loaded: { organization: Schema.NullOr(PublicOrganization) }, - Failed: {}, + Found: { organization: PublicOrganization }, + NotFound: {}, + Failed: { reason: CommonReason }, }) export type Lookup = typeof Lookup.Type +/** Why `organization.joinViaPublicInvite` failed, beyond the shared reasons. */ +export const JoinFailure = Schema.Union([ + Schema.Literals(["NotFound", "PublicInvitesDisabled", "AlreadyMember"]), + CommonReason, +]) +export type JoinFailure = typeof JoinFailure.Type + export const Model = Schema.Struct({ slug: Schema.String, lookup: Lookup, diff --git a/apps/web-foldkit/src/page/join/scene.test.ts b/apps/web-foldkit/src/page/join/scene.test.ts index ca417cd39..a0f7ba887 100644 --- a/apps/web-foldkit/src/page/join/scene.test.ts +++ b/apps/web-foldkit/src/page/join/scene.test.ts @@ -1,9 +1,10 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { failureToastFixture, makeShared, pageScene } from "../../test/pages-fixtures" +import { errorToast } from "../../data/actions" +import { makeShared, pageScene } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" -import { JoinWorkspace, RedirectToSignIn } from "./command" +import { FetchPublicOrganization, JoinWorkspace, RedirectToSignIn } from "./command" import { Message } from "./message" import { Lookup, type Model } from "./model" import { update } from "./update" @@ -15,7 +16,7 @@ const signedIn = makeShared() const signedOut = makeShared({ auth: "SignedOut", currentUser: null, organization: null, member: null }) const withOrganization = (memberCount: number): Model => ({ slug: "hazel", - lookup: Lookup.Loaded({ organization: { name: "Hazel Labs", logoUrl: null, memberCount } }), + lookup: Lookup.Found({ organization: { name: "Hazel Labs", logoUrl: null, memberCount } }), isJoining: false, }) const cardShown = Scene.Mount.resolve(CardEnterAnimation, Message.CompletedEnterAnimation()) @@ -40,19 +41,28 @@ describe("join page", () => { test("an unknown or private workspace explains itself and links home", () => { Scene.scene( pageScene(update, view, signedIn), - Scene.given<Model>({ slug: "nope", lookup: Lookup.Loaded({ organization: null }), isJoining: false }), + Scene.given<Model>({ slug: "nope", lookup: Lookup.NotFound(), isJoining: false }), cardShown, Scene.expect(Scene.role("heading", { name: "Workspace Not Found" })).toExist(), Scene.expect(Scene.role("link", { name: "Go to Home" })).toHaveAttr("href", "/"), ) }) - test("a failed lookup renders exactly like not found", () => { + // A network error used to render "Workspace Not Found", which reads as a bad link. + test("a failed lookup is not a missing workspace: it says so and retries", () => { Scene.scene( pageScene(update, view, signedIn), - Scene.given<Model>({ slug: "nope", lookup: Lookup.Failed(), isJoining: false }), + Scene.given<Model>({ slug: "hazel", lookup: Lookup.Failed({ reason: "Offline" }), isJoining: false }), cardShown, + Scene.expect(Scene.role("heading", { name: "Workspace Not Found" })).toBeAbsent(), + Scene.expect(Scene.role("heading", { name: "Couldn't load this workspace" })).toExist(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(FetchPublicOrganization({ slug: "hazel" })), + Scene.expect(Scene.text("Loading workspace...")).toExist(), + Scene.Mount.expectEnded(CardEnterAnimation), + Scene.Command.resolve(FetchPublicOrganization, Message.SucceededFetchOrganization({ organization: null })), Scene.expect(Scene.role("heading", { name: "Workspace Not Found" })).toExist(), + cardShown, ) }) @@ -79,8 +89,12 @@ describe("join page", () => { Scene.click(joinButton), Scene.Command.expectExact(JoinWorkspace({ slug: "hazel" })), Scene.expect(Scene.role("button", { name: "Joining..." })).toBeDisabled(), - Scene.Command.resolve(JoinWorkspace, Message.FailedJoinWorkspace({ toast: failureToastFixture })), - Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Scene.Command.resolve(JoinWorkspace, Message.FailedJoinWorkspace({ reason: "Offline" })), + Scene.expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Connection lost", "Check your internet connection and try again."), + }), + ), Scene.expect(joinButton).toBeEnabled(), ) }) diff --git a/apps/web-foldkit/src/page/join/story.test.ts b/apps/web-foldkit/src/page/join/story.test.ts index e19f8ebc3..468d4e617 100644 --- a/apps/web-foldkit/src/page/join/story.test.ts +++ b/apps/web-foldkit/src/page/join/story.test.ts @@ -12,7 +12,7 @@ import { init, update } from "./update" const route = AppRoute.Join.make({ slug: "hazel" }) const organization = { name: "Hazel Labs", logoUrl: null, memberCount: 3 } -const loaded: Model = { slug: "hazel", lookup: Lookup.Loaded({ organization }), isJoining: false } +const loaded: Model = { slug: "hazel", lookup: Lookup.Found({ organization }), isJoining: false } describe("join workspace", () => { test("looks the slug up on load; a missing workspace reads as not found", () => { @@ -22,16 +22,21 @@ describe("join workspace", () => { update, given(started.model), message(Message.SucceededFetchOrganization({ organization: null })), - model((current) => expect(current.lookup).toEqual(Lookup.Loaded({ organization: null }))), + model((current) => expect(current.lookup).toEqual(Lookup.NotFound())), ) }) - test("a failed lookup is recorded apart from a missing workspace", () => { + test("a failed lookup is recorded apart from a missing workspace, and can be retried", () => { story( update, given(init(route).model), - message(Message.FailedFetchOrganization()), - model((current) => expect(current.lookup).toEqual(Lookup.Failed())), + message(Message.FailedFetchOrganization({ reason: "Offline" })), + model((current) => expect(current.lookup).toEqual(Lookup.Failed({ reason: "Offline" }))), + message(Message.ClickedRetryLookup()), + Command.expectExact(FetchPublicOrganization({ slug: "hazel" })), + model((current) => expect(current.lookup).toEqual(Lookup.Loading())), + Command.resolve(FetchPublicOrganization, Message.SucceededFetchOrganization({ organization })), + model((current) => expect(current.lookup).toEqual(Lookup.Found({ organization }))), ) }) @@ -66,13 +71,13 @@ describe("join workspace", () => { ) }) - test("a rejected join shows the server's reason and unlocks the button", () => { + test("a rejected join says why and unlocks the button", () => { const failure = errorToast("Already a member", "You're already a member of this workspace.") story( update, given(loaded), message(Message.ClickedJoin()), - Command.resolve(JoinWorkspace, Message.FailedJoinWorkspace({ toast: failure })), + Command.resolve(JoinWorkspace, Message.FailedJoinWorkspace({ reason: "AlreadyMember" })), expectOutMessage(PageOutMessage.RequestedToast({ toast: failure })), model((current) => expect(current.isJoining).toBe(false)), ) diff --git a/apps/web-foldkit/src/page/join/update.ts b/apps/web-foldkit/src/page/join/update.ts index 82df0e400..81b28702c 100644 --- a/apps/web-foldkit/src/page/join/update.ts +++ b/apps/web-foldkit/src/page/join/update.ts @@ -1,5 +1,6 @@ import { modifyFields } from "foldkit/struct" -import { successToast } from "../../data/actions" +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf, successToast } from "../../data/actions" import { AppRoute, hrefOf, type RouteOf } from "../../route" import type { PageReturn } from "../contract" import { PageOutMessage } from "../out-message" @@ -17,9 +18,20 @@ export const init = (route: RouteOf<"Join">): Return => ({ export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { SucceededFetchOrganization: ({ organization }) => ({ - model: modifyFields(model, { lookup: () => Lookup.Loaded({ organization }) }), + model: modifyFields(model, { + lookup: () => (organization === null ? Lookup.NotFound() : Lookup.Found({ organization })), + }), + }), + FailedFetchOrganization: ({ reason }) => ({ + model: modifyFields(model, { lookup: () => Lookup.Failed({ reason }) }), }), - FailedFetchOrganization: () => ({ model: modifyFields(model, { lookup: () => Lookup.Failed() }) }), + ClickedRetryLookup: () => + model.lookup._tag !== "Failed" + ? { model } + : { + model: modifyFields(model, { lookup: () => Lookup.Loading() }), + commands: [FetchPublicOrganization({ slug: model.slug })], + }, ClickedSignIn: () => ({ model, commands: [RedirectToSignIn({ returnTo: hrefOf(AppRoute.Join({ slug: model.slug })) })], @@ -39,9 +51,26 @@ export const update = (model: Model, message: Message): Return => toast: successToast("Successfully joined workspace!"), }), }), - FailedJoinWorkspace: ({ toast }) => ({ + FailedJoinWorkspace: ({ reason }) => ({ model: modifyFields(model, { isJoining: () => false }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Organization not found", + description: "This organization may have been deleted.", + }, + PublicInvitesDisabled: { + title: "Public invites disabled", + description: "This organization has disabled public invites.", + }, + AlreadyMember: { + title: "Already a member", + description: "You're already a member of this workspace.", + }, + }), + ), + }), }), CompletedRedirectToSignIn: () => ({ model }), CompletedEnterAnimation: () => ({ model }), diff --git a/apps/web-foldkit/src/page/join/view.ts b/apps/web-foldkit/src/page/join/view.ts index 6aa4682dc..a6268d4b1 100644 --- a/apps/web-foldkit/src/page/join/view.ts +++ b/apps/web-foldkit/src/page/join/view.ts @@ -1,15 +1,17 @@ import { Effect } from "effect" import { Mount, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" +import { type CommonReason, commonMessage } from "~/lib/error-messages" +import { AppRoute, hrefOf } from "../../route" import { avatar } from "../../ui/avatar" -import { button } from "../../ui/button" +import { button, buttonClassName } from "../../ui/button" import { loader } from "../../ui/loader" -import type { PageViewInputs, Shared } from "../contract" -import { backgroundImage, logoContent, panelCard, panelFrame } from "../onboarding/brand-panel" +import { authShell } from "../auth/layout" +import { isSessionLoading, type PageViewInputs, type Shared } from "../contract" +import { initialsOf } from "../initials" import { animateEnter, endStyleOf } from "../onboarding/motion" import { Message } from "./message" -import type { Model, PublicOrganization } from "./model" -import { AppRoute, hrefOf } from "../../route" +import { Lookup, type Model, type PublicOrganization } from "./model" /** `cardVariants`: the card fades, rises and unblurs in. */ export const CardEnterAnimation = Mount.define("JoinCardEnterAnimation", { @@ -21,35 +23,6 @@ export const CardEnterAnimation = Mount.define("JoinCardEnterAnimation", { }), }) -const homeLink = <M>(h: HtmlBuilder<M>, className: string, children: ReadonlyArray<Html>): Html => - h.a([h.Class(className), h.Href(hrefOf(AppRoute.Root()))], [...children]) - -const leftPanel = (h: HtmlBuilder<Message>, nowMs: number, card: ReadonlyArray<Html>): Html => - panelFrame(h, [ - backgroundImage(h, nowMs, "Background"), - homeLink( - h, - "relative z-20 flex items-center gap-2", - logoContent(h, { logoClassName: "size-8 text-white" }), - ), - ...card, - ]) - -const mobileLogo = (h: HtmlBuilder<Message>): Html => - h.div( - [h.Class("mb-8 lg:hidden")], - [ - homeLink( - h, - "flex items-center gap-2", - logoContent(h, { logoClassName: "size-8", textClassName: "text-fg" }), - ), - ], - ) - -const page = (h: HtmlBuilder<Message>, left: Html, right: Html): Html => - h.main([h.Class("grid h-dvh grid-cols-1 lg:grid-cols-2")], [left, right]) - const animatedCard = (h: HtmlBuilder<Message>, children: ReadonlyArray<Html>): Html => h.div( [ @@ -61,21 +34,14 @@ const animatedCard = (h: HtmlBuilder<Message>, children: ReadonlyArray<Html>): H ) const loadingView = (h: HtmlBuilder<Message>, nowMs: number): Html => - page( + authShell( h, - leftPanel(h, nowMs, []), + nowMs, h.div( - [h.Class("flex h-full flex-col items-center justify-center p-6 lg:p-12")], - [ - h.div( - [h.Class("flex flex-col items-center gap-4")], - [ - loader(h, { className: "size-8" }), - h.p([h.Class("text-muted-fg")], ["Loading workspace..."]), - ], - ), - ], + [h.Class("m-auto flex flex-col items-center gap-4")], + [loader(h, { className: "size-8" }), h.p([h.Class("text-muted-fg")], ["Loading workspace..."])], ), + { tagline: null }, ) const warningIcon = (h: HtmlBuilder<Message>): Html => @@ -99,39 +65,48 @@ const warningIcon = (h: HtmlBuilder<Message>): Html => ], ) -const notFoundView = (h: HtmlBuilder<Message>, nowMs: number): Html => - page( - h, - leftPanel(h, nowMs, []), +const problemCard = ( + h: HtmlBuilder<Message>, + options: { readonly title: string; readonly description: string; readonly action: Html }, +): Html => + animatedCard(h, [ h.div( - [h.Class("flex h-full flex-col p-6 lg:p-12")], - [ - mobileLogo(h), - animatedCard(h, [ - h.div( - [h.Class("mb-6 flex size-20 items-center justify-center rounded-2xl bg-danger/10")], - [warningIcon(h)], - ), - h.h1([h.Class("mb-2 font-semibold text-2xl text-fg")], ["Workspace Not Found"]), - h.p( - [h.Class("mb-8 text-muted-fg")], - ["This invite link is invalid or the workspace doesn't have public invites enabled."], - ), - // `<Link to="/"><Button/></Link>`: a button nested in the anchor. - h.a( - [h.Href(hrefOf(AppRoute.Root()))], - [button(h, { intent: "secondary" }, ["Go to Home"])], - ), - ]), - ], + [h.Class("mb-6 flex size-20 items-center justify-center rounded-2xl bg-danger/10")], + [warningIcon(h)], ), + h.h1([h.Class("mb-2 font-semibold text-2xl text-fg")], [options.title]), + h.p([h.Class("mb-8 text-muted-fg")], [options.description]), + options.action, + ]) + +const notFoundView = (h: HtmlBuilder<Message>, nowMs: number): Html => + authShell( + h, + nowMs, + problemCard(h, { + title: "Workspace Not Found", + description: "This invite link is invalid or the workspace doesn't have public invites enabled.", + action: h.a( + [h.Class(buttonClassName({ intent: "secondary" })), h.Href(hrefOf(AppRoute.Root()))], + ["Go to Home"], + ), + }), + { tagline: null }, ) -const initialsOf = (name: string) => { - const words = name.split(" ") - return words.length >= 2 - ? `${words[0]?.charAt(0)}${words[1]?.charAt(0)}`.toUpperCase() - : name.substring(0, 2).toUpperCase() +/** The lookup itself failed: not a verdict on the link, so it can be retried. */ +const failedView = (h: HtmlBuilder<Message>, nowMs: number, reason: CommonReason): Html => { + const message = commonMessage(reason) + return authShell( + h, + nowMs, + problemCard(h, { + title: "Couldn't load this workspace", + description: message.description === undefined ? message.title : `${message.title}. ${message.description}`, + action: button(h, { intent: "secondary", onPress: Message.ClickedRetryLookup() }, ["Try again"]), + }), + { tagline: null }, + ) } const actions = (h: HtmlBuilder<Message>, model: Model, isSignedIn: boolean): Html => @@ -186,46 +161,36 @@ const actions = (h: HtmlBuilder<Message>, model: Model, isSignedIn: boolean): Ht ) const inviteView = (h: HtmlBuilder<Message>, model: Model, org: PublicOrganization, shared: Shared): Html => - page( + authShell( h, - leftPanel(h, shared.nowMs, [ - panelCard(h, [ - h.p( - [h.Class("text-lg text-white")], - [ - "You've been invited to join a workspace. Accept the invitation to start collaborating with your team.", - ], - ), - ]), + shared.nowMs, + animatedCard(h, [ + avatar(h, { + src: org.logoUrl, + initials: initialsOf(org.name), + size: "4xl", + className: "mb-6 shadow-lg", + }), + h.h1([h.Class("mb-2 font-semibold text-3xl text-fg")], [org.name]), + h.p( + [h.Class("mb-8 text-muted-fg")], + [String(org.memberCount), " ", org.memberCount === 1 ? "member" : "members"], + ), + actions(h, model, shared.currentUser !== null), ]), - h.div( - [h.Class("flex h-full flex-col p-6 lg:p-12")], - [ - mobileLogo(h), - animatedCard(h, [ - avatar(h, { - src: org.logoUrl, - initials: initialsOf(org.name), - size: "4xl", - className: "mb-6 shadow-lg", - }), - h.h1([h.Class("mb-2 font-semibold text-3xl text-fg")], [org.name]), - h.p( - [h.Class("mb-8 text-muted-fg")], - [String(org.memberCount), " ", org.memberCount === 1 ? "member" : "members"], - ), - actions(h, model, shared.currentUser !== null), - ]), - ], - ), + { + tagline: + "You've been invited to join a workspace. Accept the invitation to start collaborating with your team.", + }, ) -/** `useAuth().isLoading`: the session check is pending, or signed in while `user.me` is pending. */ -const isAuthLoading = (shared: Shared) => - shared.auth === "Loading" || (shared.auth === "SignedIn" && shared.currentUser === null) - -export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => { - if (model.lookup._tag === "Loading" || isAuthLoading(shared)) return loadingView(h, shared.nowMs) - const organization = model.lookup._tag === "Loaded" ? model.lookup.organization : null - return organization ? inviteView(h, model, organization, shared) : notFoundView(h, shared.nowMs) -}) +export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => + isSessionLoading(shared) + ? loadingView(h, shared.nowMs) + : Lookup.match(model.lookup, { + Loading: () => loadingView(h, shared.nowMs), + Found: ({ organization }) => inviteView(h, model, organization, shared), + NotFound: () => notFoundView(h, shared.nowMs), + Failed: ({ reason }) => failedView(h, shared.nowMs, reason), + }), +) diff --git a/apps/web-foldkit/src/page/onboarding/setup-organization/scene.test.ts b/apps/web-foldkit/src/page/onboarding/setup-organization/scene.test.ts index 547130131..cb08f71aa 100644 --- a/apps/web-foldkit/src/page/onboarding/setup-organization/scene.test.ts +++ b/apps/web-foldkit/src/page/onboarding/setup-organization/scene.test.ts @@ -22,9 +22,10 @@ describe("setup organization", () => { Scene.Command.expectExact(CreateWorkspace.CreateWorkspace({ name: "Acme Corp!", slug: "acme-corp" })), Scene.Command.resolve( CreateWorkspace.CreateWorkspace, - CreateWorkspace.Message.FailedCreateWorkspace({ error: "That workspace URL is taken. Pick another." }), + CreateWorkspace.Message.FailedCreateWorkspace({ reason: "SlugTaken" }), ), Scene.expect(Scene.role("alert")).toHaveText("That workspace URL is taken. Pick another."), + Scene.expect(create).toBeEnabled(), ) }) From 2431a54e02e48f4b737571284251b34214063405 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:22:23 +0200 Subject: [PATCH 27/52] fix(web): drop the removed ClerkUserFetchError from the common error union Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/core/lib/error-messages.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web-foldkit/src/core/lib/error-messages.ts b/apps/web-foldkit/src/core/lib/error-messages.ts index fd6edcc65..59b6620e1 100644 --- a/apps/web-foldkit/src/core/lib/error-messages.ts +++ b/apps/web-foldkit/src/core/lib/error-messages.ts @@ -1,6 +1,5 @@ import type { RateLimitExceededError } from "@hazel/domain" import type { - ClerkUserFetchError, InternalServerError, InvalidBearerTokenError, InvalidJwtPayloadError, @@ -64,7 +63,6 @@ export type CommonError = | SessionLoadError | InvalidJwtPayloadError | InvalidBearerTokenError - | ClerkUserFetchError // Server catch-alls | InternalServerError | WorkflowServiceUnavailableError @@ -128,7 +126,6 @@ export const commonReasonOf = (error: CommonError): CommonReason => InvalidJwtPayloadError: signedOut, InvalidBearerTokenError: signedOut, SessionLoadError: unavailable, - ClerkUserFetchError: unavailable, WorkflowServiceUnavailableError: unavailable, CollectionSyncEffectError: unavailable, RateLimitExceededError: (): CommonReason => "RateLimited", @@ -158,7 +155,6 @@ const commonTags: ReadonlySet<string> = new Set<CommonError["_tag"]>([ "InvalidJwtPayloadError", "InvalidBearerTokenError", "SessionLoadError", - "ClerkUserFetchError", "WorkflowServiceUnavailableError", "CollectionSyncEffectError", "RateLimitExceededError", From e70a4e08ff5e8e82cbb63767e88a51fb14d82e1c Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:23:43 +0200 Subject: [PATCH 28/52] refactor(web): explicit loading unions for the entry pages' live queries Profile, home (members and DM rows), chat index and the notifications inbox hold Loading | Loaded (profile: Loading | Found | NotFound) instead of null-means-loading, so the profile no longer flashes "User not found" on every visit. Mark-read and create-DM failures are catchTags + catchCommon reasons; a failed mark-read now says so (a deleted notification stays quiet) instead of being dropped as String(error). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/page/chat-index/model.ts | 15 +++++-- .../src/page/chat-index/page.test.ts | 5 ++- .../src/page/chat-index/subscription.ts | 2 +- .../web-foldkit/src/page/chat-index/update.ts | 8 ++-- apps/web-foldkit/src/page/chat-index/view.ts | 4 +- apps/web-foldkit/src/page/home/commands.ts | 19 ++++---- apps/web-foldkit/src/page/home/message.ts | 5 +-- apps/web-foldkit/src/page/home/model.ts | 29 ++++++++++-- apps/web-foldkit/src/page/home/story.test.ts | 13 ++++-- apps/web-foldkit/src/page/home/update.ts | 44 ++++++++++++++----- apps/web-foldkit/src/page/home/view.ts | 4 +- .../src/page/notifications/inbox/message.ts | 4 +- .../src/page/notifications/inbox/model.ts | 16 ++++++- .../src/page/notifications/inbox/page.test.ts | 13 +++--- .../page/notifications/inbox/story.test.ts | 32 ++++++++++---- .../src/page/notifications/inbox/update.ts | 33 ++++++++++---- .../src/page/notifications/inbox/view.ts | 4 +- apps/web-foldkit/src/page/profile/model.ts | 12 ++++- .../src/page/profile/scene.test.ts | 15 ++++++- apps/web-foldkit/src/page/profile/update.ts | 12 +++-- apps/web-foldkit/src/page/profile/view.ts | 15 +++++-- 21 files changed, 222 insertions(+), 82 deletions(-) diff --git a/apps/web-foldkit/src/page/chat-index/model.ts b/apps/web-foldkit/src/page/chat-index/model.ts index 205dd1878..44ca9dd32 100644 --- a/apps/web-foldkit/src/page/chat-index/model.ts +++ b/apps/web-foldkit/src/page/chat-index/model.ts @@ -1,5 +1,6 @@ import { ChannelId, UserId } from "@hazel/schema" import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" import * as Tabs from "../../ui/tabs" /** One row of `routes/_app/$orgSlug/chat/index.tsx`, already split by type for the signed-in user. */ @@ -37,9 +38,15 @@ export const Presence = Schema.Struct({ }) export type Presence = typeof Presence.Type +/** The channel live query; `Loading` shows the legacy full-height loader. */ +export const Channels = defineTaggedUnion({ + Loading: {}, + Loaded: { groups: ChannelGroups }, +}) +export type Channels = typeof Channels.Type + export const Model = Schema.Struct({ - /** `null` while the live query loads (the legacy full-height loader). */ - groups: Schema.NullOr(ChannelGroups), + channels: Channels, /** `useUserPresence` for the other member of each single DM, keyed by user id. */ presence: Schema.Record(Schema.String, Presence), tabs: Tabs.Model, @@ -53,8 +60,8 @@ export const otherMembers = (channel: ChannelSummary, currentUserId: string | un channel.members.filter((member) => member.userId !== currentUserId) /** Users whose presence the single-DM cards show. */ -export const singleDmPartnerIds = (groups: ChannelGroups | null, currentUserId: string | undefined) => - (groups?.dmChannels ?? []).flatMap((channel) => { +export const singleDmPartnerIds = (channels: Channels, currentUserId: string | undefined) => + (channels._tag === "Loaded" ? channels.groups.dmChannels : []).flatMap((channel) => { const others = otherMembers(channel, currentUserId) return channel.type === "single" && others.length === 1 && others[0] ? [others[0].userId] : [] }) diff --git a/apps/web-foldkit/src/page/chat-index/page.test.ts b/apps/web-foldkit/src/page/chat-index/page.test.ts index 475d9bffd..961cef122 100644 --- a/apps/web-foldkit/src/page/chat-index/page.test.ts +++ b/apps/web-foldkit/src/page/chat-index/page.test.ts @@ -6,7 +6,7 @@ import * as Tabs from "../../ui/tabs" import { PageOutMessage } from "../out-message" import { type ChannelRow, groupChannels } from "./group" import { Message } from "./message" -import { singleDmPartnerIds } from "./model" +import { Channels, singleDmPartnerIds } from "./model" import { init, update } from "./update" /** The chat index's grouping (the legacy useMemo) and its update loop. */ @@ -64,7 +64,8 @@ describe("chat index grouping", () => { }) test("watches presence only for single-DM partners", () => { - expect(singleDmPartnerIds(groupChannels(rows, me), me)).toEqual([grace]) + expect(singleDmPartnerIds(Channels.Loaded({ groups: groupChannels(rows, me) }), me)).toEqual([grace]) + expect(singleDmPartnerIds(Channels.Loading(), me)).toEqual([]) }) }) diff --git a/apps/web-foldkit/src/page/chat-index/subscription.ts b/apps/web-foldkit/src/page/chat-index/subscription.ts index 09efd6100..5262b463e 100644 --- a/apps/web-foldkit/src/page/chat-index/subscription.ts +++ b/apps/web-foldkit/src/page/chat-index/subscription.ts @@ -53,7 +53,7 @@ export const subscriptions = Subscription.make<PageSubscriptionInput<Model>, Mes { userIds: Schema.Array(UserId) }, { modelToDependencies: ({ model, shared }) => ({ - userIds: singleDmPartnerIds(model.groups, shared.currentUser?.id), + userIds: singleDmPartnerIds(model.channels, shared.currentUser?.id), }), dependenciesToStream: ({ userIds }) => userIds.length === 0 diff --git a/apps/web-foldkit/src/page/chat-index/update.ts b/apps/web-foldkit/src/page/chat-index/update.ts index 183d22990..83e41c75d 100644 --- a/apps/web-foldkit/src/page/chat-index/update.ts +++ b/apps/web-foldkit/src/page/chat-index/update.ts @@ -4,15 +4,17 @@ import * as Tabs from "../../ui/tabs" import type { PageReturn } from "../contract" import { PageOutMessage } from "../out-message" import { Message } from "./message" -import { type Model, TABS_ID } from "./model" +import { Channels, type Model, TABS_ID } from "./model" export const init = (): PageReturn<Model, Message> => ({ - model: { groups: null, presence: {}, tabs: Tabs.init({ id: TABS_ID, selectedKey: "public" }) }, + model: { channels: Channels.Loading(), presence: {}, tabs: Tabs.init({ id: TABS_ID, selectedKey: "public" }) }, }) export const update = (model: Model, message: Message): PageReturn<Model, Message> => Message.match<PageReturn<Model, Message>>(message, { - UpdatedChannels: ({ groups }) => ({ model: modifyFields(model, { groups: () => groups }) }), + UpdatedChannels: ({ groups }) => ({ + model: modifyFields(model, { channels: () => Channels.Loaded({ groups }) }), + }), UpdatedPresence: ({ presence }) => ({ model: modifyFields(model, { presence: () => presence }) }), GotTabsMessage: ({ message: tabsMessage }) => { const result = Tabs.update(model.tabs, tabsMessage) diff --git a/apps/web-foldkit/src/page/chat-index/view.ts b/apps/web-foldkit/src/page/chat-index/view.ts index 4a17534cc..4dd649f59 100644 --- a/apps/web-foldkit/src/page/chat-index/view.ts +++ b/apps/web-foldkit/src/page/chat-index/view.ts @@ -135,13 +135,13 @@ const panels = ( } export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => { - const groups = model.groups - if (groups === null) { + if (model.channels._tag === "Loading") { return h.div( [h.Class("flex h-screen items-center justify-center")], [loader(h, { className: "size-8" })], ) } + const { groups } = model.channels return h.div( [h.Class("flex flex-col gap-6 px-4 py-6 lg:px-8")], [ diff --git a/apps/web-foldkit/src/page/home/commands.ts b/apps/web-foldkit/src/page/home/commands.ts index 3c600a46e..226f55a6c 100644 --- a/apps/web-foldkit/src/page/home/commands.ts +++ b/apps/web-foldkit/src/page/home/commands.ts @@ -2,7 +2,7 @@ import { OrganizationId, UserId } from "@hazel/schema" import { Effect, Schema } from "effect" import { Command, Mount } from "foldkit" import * as Dom from "foldkit/dom" -import { failureToast, settle } from "../../data/actions" +import { catchCommon } from "../../data/actions" import { HazelRpc } from "../../rpc" import { searchFieldIds } from "../../ui/search-field" import { Message } from "./message" @@ -31,14 +31,15 @@ export const CreateDm = Command.define("CreateDm", { args: { organizationId: OrganizationId, userId: UserId, name: Schema.String }, messages: [Message.SucceededCreateDm, Message.FailedCreateDm], execute: ({ organizationId, userId, name }) => - Effect.gen(function* () { - const rpc = yield* HazelRpc - return yield* settle( - rpc("channel.createDm", { organizationId, participantIds: [userId], type: "single" }), - (result) => Message.SucceededCreateDm({ channelId: result.data.id, name }), - (cause) => Message.FailedCreateDm({ toast: failureToast(cause, "friendly") }), - ) - }), + HazelRpc.use((rpc) => + rpc("channel.createDm", { organizationId, participantIds: [userId], type: "single" }), + ).pipe( + Effect.map((result) => Message.SucceededCreateDm({ channelId: result.data.id, name })), + Effect.catchTags({ + DmChannelAlreadyExistsError: () => Effect.succeed(Message.FailedCreateDm({ reason: "AlreadyExists" })), + }), + catchCommon((reason) => Message.FailedCreateDm({ reason })), + ), }) /** `handleCopyEmail`. */ diff --git a/apps/web-foldkit/src/page/home/message.ts b/apps/web-foldkit/src/page/home/message.ts index 10aa750b3..88a97c0f5 100644 --- a/apps/web-foldkit/src/page/home/message.ts +++ b/apps/web-foldkit/src/page/home/message.ts @@ -3,8 +3,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import * as Interaction from "../../ui/aria/interaction" import * as Menu from "../../ui/menu" -import { ToastRequest } from "../../overlay/toasts" -import { DirectoryMember, DmRow } from "./model" +import { CreateDmFailure, DirectoryMember, DmRow } from "./model" export const Message = defineMessageUnion({ UpdatedMembers: { members: Schema.Array(DirectoryMember) }, @@ -17,7 +16,7 @@ export const Message = defineMessageUnion({ GotMemberMenuMessage: { userId: UserId, message: Menu.Message }, UpdatedDmChannels: { rows: Schema.Array(DmRow) }, SucceededCreateDm: { channelId: ChannelId, name: Schema.String }, - FailedCreateDm: { toast: ToastRequest }, + FailedCreateDm: { reason: CreateDmFailure }, SucceededCopyEmail: { email: Schema.String }, FailedCopyEmail: {}, }) diff --git a/apps/web-foldkit/src/page/home/model.ts b/apps/web-foldkit/src/page/home/model.ts index a96733343..78968f01d 100644 --- a/apps/web-foldkit/src/page/home/model.ts +++ b/apps/web-foldkit/src/page/home/model.ts @@ -1,6 +1,7 @@ import { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Schema } from "effect" import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../ui/aria/interaction" import * as Menu from "../../ui/menu" @@ -32,11 +33,27 @@ export const DmRequest = defineTaggedUnion({ }) export type DmRequest = typeof DmRequest.Type +/** The member directory live query; `Loading` shows the legacy loader. */ +export const Members = defineTaggedUnion({ + Loading: {}, + Loaded: { members: Schema.Array(DirectoryMember) }, +}) +export type Members = typeof Members.Type + +/** The DM live query; a Message press before it loads waits in `DmRequest.AwaitingChannels`. */ +export const DmChannels = defineTaggedUnion({ + Loading: {}, + Loaded: { rows: Schema.Array(DmRow) }, +}) +export type DmChannels = typeof DmChannels.Type + +/** Why `channel.createDm` failed. `AlreadyExists`: the server has a DM the synced rows don't show yet. */ +export const CreateDmFailure = Schema.Union([Schema.Literals(["AlreadyExists"]), CommonReason]) +export type CreateDmFailure = typeof CreateDmFailure.Type + export const Model = Schema.Struct({ - /** `null` until the live query is ready (the legacy loader). */ - members: Schema.NullOr(Schema.Array(DirectoryMember)), - /** `null` until the DM live query is ready. */ - dmRows: Schema.NullOr(Schema.Array(DmRow)), + members: Members, + dmChannels: DmChannels, dmRequest: DmRequest, searchQuery: Schema.String, /** One "Member actions" menu per member, keyed by user id. */ @@ -56,6 +73,10 @@ export const menuEntries: ReadonlyArray<Menu.Entry> = [ Menu.item("copy-email"), ] +/** The loaded members, or none while the query loads. */ +export const loadedMembers = (model: Model): ReadonlyArray<DirectoryMember> => + model.members._tag === "Loaded" ? model.members.members : [] + /** `filteredMembers`: case-insensitive match on the full name or the email. */ export const filterMembers = ( members: ReadonlyArray<DirectoryMember>, diff --git a/apps/web-foldkit/src/page/home/story.test.ts b/apps/web-foldkit/src/page/home/story.test.ts index 608638dec..669a17792 100644 --- a/apps/web-foldkit/src/page/home/story.test.ts +++ b/apps/web-foldkit/src/page/home/story.test.ts @@ -10,7 +10,7 @@ import { Message } from "./message" import { type DirectoryMember, DmRequest, type DmRow, filterMembers, type Model } from "./model" import { init, update } from "./update" import { sharedDefaults } from "../test-shared" -import { failureToastFixture } from "../../test/pages-fixtures" +import { errorToast } from "../../data/actions" // `ui/aria/interaction` names `document` when its module loads; the update loop never touches it. vi.hoisted(() => { @@ -167,8 +167,15 @@ describe("org home failures and guards", () => { pageUpdate, given(loaded()), message(Message.PressedMessageMember({ userId: grace, name: "Grace Hopper" })), - Command.resolve(CreateDm, Message.FailedCreateDm({ toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: { ...failureToastFixture, id: "home-create-dm" } })), + Command.resolve(CreateDm, Message.FailedCreateDm({ reason: "Offline" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + ...errorToast("Connection lost", "Check your internet connection and try again."), + id: "home-create-dm", + }, + }), + ), model((current) => expect(current.dmRequest).toEqual(DmRequest.Idle())), ) }) diff --git a/apps/web-foldkit/src/page/home/update.ts b/apps/web-foldkit/src/page/home/update.ts index fd632e14a..d7fb74cc5 100644 --- a/apps/web-foldkit/src/page/home/update.ts +++ b/apps/web-foldkit/src/page/home/update.ts @@ -2,7 +2,8 @@ import type { ChannelId } from "@hazel/schema" import { Option } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" -import { errorToast, loadingToast, successToast } from "../../data/actions" +import { failureMessage } from "~/lib/error-messages" +import { errorToast, errorToastOf, loadingToast, successToast } from "../../data/actions" import type { ToastRequest } from "../../overlay/toasts" import * as Interaction from "../../ui/aria/interaction" import { AppRoute } from "../../route" @@ -12,14 +13,24 @@ import { PageOutMessage, requestedOrgNavigation } from "../out-message" import { CopyEmail, CreateDm, FocusSearch } from "./commands" import { findExistingDmChannel } from "./dm" import { Message } from "./message" -import { type DirectoryMember, DmRequest, type DmRow, menuEntries, menuIdOf, type Model } from "./model" +import { + type DirectoryMember, + DmChannels, + DmRequest, + type DmRow, + loadedMembers, + Members, + menuEntries, + menuIdOf, + type Model, +} from "./model" type Return = PageReturn<Model, Message> export const init = (): Return => ({ model: { - members: null, - dmRows: null, + members: Members.Loading(), + dmChannels: DmChannels.Loading(), dmRequest: DmRequest.Idle(), searchQuery: "", menus: {}, @@ -48,7 +59,7 @@ const reconcileMenus = (model: Model, members: ReadonlyArray<DirectoryMember>): ) const memberByUserId = (model: Model, userId: string) => - Option.fromNullishOr(model.members?.find((member) => member.id === userId)) + Option.fromNullishOr(loadedMembers(model).find((member) => member.id === userId)) const withDmRequest = (model: Model, dmRequest: DmRequest): Model => modifyFields(model, { dmRequest: () => dmRequest }) @@ -73,7 +84,10 @@ const openDm = (model: Model, shared: Shared, member: DirectoryMember, rows: Rea const requestDm = (model: Model, shared: Shared, member: DirectoryMember): Return => { if (model.dmRequest._tag !== "Idle" || !shared.organization || !shared.currentUser) return { model } const awaiting = withDmRequest(model, DmRequest.AwaitingChannels({ userId: member.id })) - return model.dmRows === null ? { model: awaiting } : openDm(awaiting, shared, member, model.dmRows) + return DmChannels.match(model.dmChannels, { + Loading: () => ({ model: awaiting }), + Loaded: ({ rows }) => openDm(awaiting, shared, member, rows), + }) } const updateMenu = ( @@ -108,7 +122,7 @@ export const update = (model: Model, message: Message, shared: Shared): Return = Message.match<Return>(message, { UpdatedMembers: ({ members }) => ({ model: modifyFields(model, { - members: () => members, + members: () => Members.Loaded({ members }), menus: () => reconcileMenus(model, members), }), }), @@ -132,7 +146,7 @@ export const update = (model: Model, message: Message, shared: Shared): Return = onSome: (member) => updateMenu(model, shared, member, menuMessage), }), UpdatedDmChannels: ({ rows }) => { - const next = modifyFields(model, { dmRows: () => rows }) + const next = modifyFields(model, { dmChannels: () => DmChannels.Loaded({ rows }) }) const { dmRequest } = model if (dmRequest._tag !== "AwaitingChannels") return { model: next } return Option.match(memberByUserId(model, dmRequest.userId), { @@ -147,9 +161,19 @@ export const update = (model: Model, message: Message, shared: Shared): Return = id: DM_TOAST_ID, }), }), - FailedCreateDm: ({ toast: failure }) => ({ + FailedCreateDm: ({ reason }) => ({ model: withDmRequest(model, DmRequest.Idle()), - outMessage: PageOutMessage.RequestedToast({ toast: { ...failure, id: DM_TOAST_ID } }), + outMessage: toast({ + ...errorToastOf( + failureMessage(reason, { + AlreadyExists: { + title: "You already have a conversation with them", + description: "It will appear in your messages in a moment.", + }, + }), + ), + id: DM_TOAST_ID, + }), }), SucceededCopyEmail: ({ email }) => ({ model, diff --git a/apps/web-foldkit/src/page/home/view.ts b/apps/web-foldkit/src/page/home/view.ts index fcaf7581d..41845d3b1 100644 --- a/apps/web-foldkit/src/page/home/view.ts +++ b/apps/web-foldkit/src/page/home/view.ts @@ -181,12 +181,12 @@ const memberListContent = ( model: Model, currentUserId: string | undefined, ): ReadonlyArray<Html> => { - if (model.members === null) { + if (model.members._tag === "Loading") { return [ h.div([h.Class("flex items-center justify-center py-8")], [loader(h, { className: "size-8" })]), ] } - const filtered = filterMembers(model.members, model.searchQuery) + const filtered = filterMembers(model.members.members, model.searchQuery) if (filtered.length === 0) { return [ h.div( diff --git a/apps/web-foldkit/src/page/notifications/inbox/message.ts b/apps/web-foldkit/src/page/notifications/inbox/message.ts index dc99aee1b..e718c932c 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/message.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/message.ts @@ -1,12 +1,12 @@ import { NotificationId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { NotificationItem } from "./model" +import { MarkReadFailure, NotificationItem } from "./model" export const Message = defineMessageUnion({ UpdatedNotifications: { notifications: Schema.Array(NotificationItem) }, ClickedNotification: { id: NotificationId }, SucceededMarkNotificationRead: { id: NotificationId }, - FailedMarkNotificationRead: { id: NotificationId, reason: Schema.String }, + FailedMarkNotificationRead: { id: NotificationId, reason: MarkReadFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/notifications/inbox/model.ts b/apps/web-foldkit/src/page/notifications/inbox/model.ts index 6dcc69fff..243bd049e 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/model.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/model.ts @@ -1,5 +1,7 @@ import { NotificationId } from "@hazel/schema" import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" /** `NotificationCategory` (`hooks/use-grouped-notifications.ts`), one per route. */ export const Category = Schema.Literals(["all", "general", "threads", "dms"]) @@ -25,9 +27,19 @@ export const NotificationItem = Schema.Struct({ }) export type NotificationItem = typeof NotificationItem.Type -/** `notifications` is null until the live query first emits (legacy `isLoading`). */ +/** The notifications live query: `Loading` until it first emits (legacy `isLoading`). */ +export const Notifications = defineTaggedUnion({ + Loading: {}, + Loaded: { items: Schema.Array(NotificationItem) }, +}) +export type Notifications = typeof Notifications.Type + +/** Why `notification.update` failed. `NotFound`: the notification is gone, so there is nothing to mark. */ +export const MarkReadFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type MarkReadFailure = typeof MarkReadFailure.Type + export const Model = Schema.Struct({ category: Category, - notifications: Schema.NullOr(Schema.Array(NotificationItem)), + notifications: Notifications, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/notifications/inbox/page.test.ts b/apps/web-foldkit/src/page/notifications/inbox/page.test.ts index 991cd4590..6c2240dcb 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/page.test.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/page.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "vitest" import type { Shared } from "../../contract" import { formatDistanceToNow, groupByTime } from "./format" import { Message } from "./message" -import type { Model, NotificationItem } from "./model" +import { type Model, type NotificationItem, Notifications } from "./model" import { MarkNotificationRead, routeChanged, update } from "./update" import { inboxView } from "./view" import { sharedDefaults } from "../../test-shared" @@ -62,12 +62,13 @@ const olderThread = item(4, { channel: { name: "Onboarding", type: "thread" }, }) -const inbox: Model = { category: "all", notifications: [unreadChannel, readDm, unreadOrphan, olderThread] } +const items = [unreadChannel, readDm, unreadOrphan, olderThread] +const inbox: Model = { category: "all", notifications: Notifications.Loaded({ items }) } const view = Scene.withViewInputs(inboxView, { shared }) describe("notifications inbox", () => { test("groups by day and week, newest group first", () => { - expect(groupByTime(inbox.notifications ?? [], NOW).map((group) => group.label)).toEqual([ + expect(groupByTime(items, NOW).map((group) => group.label)).toEqual([ "Today", "Yesterday", "This Week", @@ -105,7 +106,7 @@ describe("notifications inbox", () => { Scene.Command.expectExact(MarkNotificationRead), Scene.Command.resolve( MarkNotificationRead, - Message.FailedMarkNotificationRead({ id: unreadOrphan.id, reason: "offline" }), + Message.FailedMarkNotificationRead({ id: unreadOrphan.id, reason: "NotFound" }), ), ) }) @@ -126,7 +127,7 @@ describe("notifications inbox", () => { ) Scene.scene( { update, view: view() }, - Scene.given({ ...dms, notifications: [unreadChannel] }), + Scene.given({ ...dms, notifications: Notifications.Loaded({ items: [unreadChannel] }) }), Scene.expect(Scene.text("No DM notifications")).toExist(), ) }) @@ -134,7 +135,7 @@ describe("notifications inbox", () => { test("shows the loader until the first rows arrive", () => { Scene.scene( { update, view: view() }, - Scene.given<Model>({ category: "all", notifications: null }), + Scene.given<Model>({ category: "all", notifications: Notifications.Loading() }), Scene.expect(Scene.role("progressbar")).toExist(), Scene.expect(Scene.text("No notifications")).toBeAbsent(), ) diff --git a/apps/web-foldkit/src/page/notifications/inbox/story.test.ts b/apps/web-foldkit/src/page/notifications/inbox/story.test.ts index e10793861..f7d427208 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/story.test.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/story.test.ts @@ -1,12 +1,14 @@ // @vitest-environment jsdom import { NotificationId } from "@hazel/schema" import { Schema } from "effect" -import { Command, expectNoOutMessage, given, message, model, story } from "foldkit/story" +import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { uuid } from "../../../test/pages-fixtures" +import { errorToast } from "../../../data/actions" import { AppRoute } from "../../../route" +import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import type { Model, NotificationItem } from "./model" +import { type Model, type NotificationItem, Notifications } from "./model" import { init, MarkNotificationRead, routeChanged, update } from "./update" /** The inbox update loop: live rows, marking unread rows read, and the category tabs. */ @@ -24,17 +26,17 @@ const notification = (n: number, isUnread: boolean): NotificationItem => ({ }) const unread = notification(1, true) const read = notification(2, false) -const loaded: Model = { category: "all", notifications: [unread, read] } +const loaded: Model = { category: "all", notifications: Notifications.Loaded({ items: [unread, read] }) } describe("notifications inbox update", () => { test("starts loading on the route's tab and takes the live rows", () => { const started = init(AppRoute.NotificationsThreads.make({ orgSlug: "hazel" })) - expect(started.model).toEqual({ category: "threads", notifications: null }) + expect(started.model).toEqual({ category: "threads", notifications: Notifications.Loading() }) story( update, given(started.model), message(Message.UpdatedNotifications({ notifications: [unread] })), - model((current) => expect(current.notifications).toEqual([unread])), + model((current) => expect(current.notifications).toEqual(Notifications.Loaded({ items: [unread] }))), ) }) @@ -55,14 +57,28 @@ describe("notifications inbox update", () => { story(update, given(loaded), message(Message.ClickedNotification({ id: notification(3, true).id })), Command.expectNone()) }) - test("a failed mark-read leaves the row unread and reports nothing", () => { + test("a failed mark-read leaves the row unread and says so", () => { story( update, given(loaded), message(Message.ClickedNotification({ id: unread.id })), - Command.resolve(MarkNotificationRead, Message.FailedMarkNotificationRead({ id: unread.id, reason: "offline" })), + Command.resolve(MarkNotificationRead, Message.FailedMarkNotificationRead({ id: unread.id, reason: "Offline" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Couldn't mark the notification as read", "Check your internet connection and try again."), + }), + ), + model((current) => expect(current).toEqual(loaded)), + ) + }) + + test("a notification that is already gone fails quietly", () => { + story( + update, + given(loaded), + message(Message.ClickedNotification({ id: unread.id })), + Command.resolve(MarkNotificationRead, Message.FailedMarkNotificationRead({ id: unread.id, reason: "NotFound" })), expectNoOutMessage(), - model((current) => expect(current.notifications?.[0]?.isUnread).toBe(true)), ) }) diff --git a/apps/web-foldkit/src/page/notifications/inbox/update.ts b/apps/web-foldkit/src/page/notifications/inbox/update.ts index d414b362c..ffa088322 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/update.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/update.ts @@ -2,11 +2,14 @@ import { NotificationId } from "@hazel/schema" import { Array, Clock, Effect, Match, Option } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" +import { catchCommon, errorToastOf } from "../../../data/actions" import { HazelRpc } from "../../../rpc" import type { RouteOf } from "../../../route" +import { commonMessage } from "~/lib/error-messages" import type { PageReturn } from "../../contract" +import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import type { Category, Model } from "./model" +import { type Category, type Model, Notifications } from "./model" export type InboxRoute = RouteOf< "NotificationsAll" | "NotificationsGeneral" | "NotificationsThreads" | "NotificationsDms" @@ -32,14 +35,16 @@ export const MarkNotificationRead = Command.define("MarkNotificationRead", { yield* client("notification.update", { id, readAt: new Date(nowMs) }) return Message.SucceededMarkNotificationRead({ id }) }).pipe( - Effect.catch((error) => - Effect.succeed(Message.FailedMarkNotificationRead({ id, reason: String(error) })), - ), + Effect.catchTags({ + NotificationNotFoundError: () => + Effect.succeed(Message.FailedMarkNotificationRead({ id, reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedMarkNotificationRead({ id, reason })), ), }) export const init = (route: InboxRoute): PageReturn<Model, Message> => ({ - model: { category: categoryOf(route), notifications: null }, + model: { category: categoryOf(route), notifications: Notifications.Loading() }, }) /** The category tabs share one instance, so switching tabs keeps the loaded rows. */ @@ -49,19 +54,31 @@ export const routeChanged = (model: Model, route: InboxRoute): PageReturn<Model, const isUnread = (model: Model, id: NotificationId) => Option.exists( - Array.findFirst(model.notifications ?? [], (item) => item.id === id), + Array.findFirst(model.notifications._tag === "Loaded" ? model.notifications.items : [], (item) => item.id === id), (item) => item.isUnread, ) export const update = (model: Model, message: Message): PageReturn<Model, Message> => Message.match<PageReturn<Model, Message>>(message, { UpdatedNotifications: ({ notifications }) => ({ - model: modifyFields(model, { notifications: () => notifications }), + model: modifyFields(model, { notifications: () => Notifications.Loaded({ items: notifications }) }), }), // Legacy `handleClick`: only unread notifications are marked; a link still navigates. ClickedNotification: ({ id }) => isUnread(model, id) ? { model, commands: [MarkNotificationRead({ id })] } : { model }, // Legacy rows read `readAt` straight from the collection, so the write changes nothing here. SucceededMarkNotificationRead: () => ({ model }), - FailedMarkNotificationRead: () => ({ model }), + // A notification that is gone has nothing left to mark; anything else stays unread and says so. + FailedMarkNotificationRead: ({ reason }) => + reason === "NotFound" + ? { model } + : { + model, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf({ + title: "Couldn't mark the notification as read", + description: commonMessage(reason).description, + }), + }), + }, }) diff --git a/apps/web-foldkit/src/page/notifications/inbox/view.ts b/apps/web-foldkit/src/page/notifications/inbox/view.ts index 6aa0c1a0d..088db3c63 100644 --- a/apps/web-foldkit/src/page/notifications/inbox/view.ts +++ b/apps/web-foldkit/src/page/notifications/inbox/view.ts @@ -153,13 +153,13 @@ const notificationList = ( nowMs: number, ): Html => { const copy = COPY[model.category] - if (model.notifications === null) { + if (model.notifications._tag === "Loading") { return h.div( [h.Class("flex h-full items-center justify-center py-16")], [loader(h, { className: "size-8" })], ) } - const filtered = filterByCategory(model.notifications, model.category) + const filtered = filterByCategory(model.notifications.items, model.category) if (filtered.length === 0) { return emptyState(h, { icon: (className) => IconBell(h, { className }), diff --git a/apps/web-foldkit/src/page/profile/model.ts b/apps/web-foldkit/src/page/profile/model.ts index b9b15b38c..9c6a1435b 100644 --- a/apps/web-foldkit/src/page/profile/model.ts +++ b/apps/web-foldkit/src/page/profile/model.ts @@ -1,5 +1,6 @@ import { UserId } from "@hazel/schema" import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" /** The user shown on `/$orgSlug/profile/$userId`, with their stored presence. */ export const ProfileUser = Schema.Struct({ @@ -12,9 +13,16 @@ export const ProfileUser = Schema.Struct({ }) export type ProfileUser = typeof ProfileUser.Type +/** The user live query: `Loading` until it is ready, then the row or `NotFound`. */ +export const ProfileLookup = defineTaggedUnion({ + Loading: {}, + Found: { user: ProfileUser }, + NotFound: {}, +}) +export type ProfileLookup = typeof ProfileLookup.Type + export const Model = Schema.Struct({ userId: UserId, - /** `null` until found; legacy renders "User not found" for both. */ - user: Schema.NullOr(ProfileUser), + user: ProfileLookup, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/profile/scene.test.ts b/apps/web-foldkit/src/page/profile/scene.test.ts index f3aa4997c..7b95ba535 100644 --- a/apps/web-foldkit/src/page/profile/scene.test.ts +++ b/apps/web-foldkit/src/page/profile/scene.test.ts @@ -5,7 +5,7 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { makeShared, pageScene, userId, uuid } from "../../test/pages-fixtures" import { Message } from "./message" -import type { ProfileUser } from "./model" +import { ProfileLookup, type ProfileUser } from "./model" import { init, update } from "./update" import { view } from "./view" @@ -61,10 +61,21 @@ describe("profile page", () => { test("a user who disappears from the query shows not found", () => { Scene.scene( pageScene(update, view, shared), - Scene.given({ userId: graceId, user: grace }), + Scene.given({ userId: graceId, user: ProfileLookup.Found({ user: grace }) }), Scene.Subscription.emit(Message.UpdatedProfileUser({ user: null })), Scene.expect(Scene.text("User not found")).toExist(), Scene.expect(Scene.text("Grace Hopper")).toBeAbsent(), ) }) + + // `null` used to mean both "loading" and "missing", so every visit flashed "User not found". + test("the page loads before it can say the user is missing", () => { + Scene.scene( + pageScene(update, view, shared), + Scene.given(init(graceId).model), + Scene.expect(Scene.text("User not found")).toBeAbsent(), + Scene.Subscription.emit(Message.UpdatedProfileUser({ user: null })), + Scene.expect(Scene.text("User not found")).toExist(), + ) + }) }) diff --git a/apps/web-foldkit/src/page/profile/update.ts b/apps/web-foldkit/src/page/profile/update.ts index 412d8d817..bcccd47dd 100644 --- a/apps/web-foldkit/src/page/profile/update.ts +++ b/apps/web-foldkit/src/page/profile/update.ts @@ -2,11 +2,17 @@ import type { UserId } from "@hazel/schema" import { modifyFields } from "foldkit/struct" import type { PageReturn } from "../contract" import { Message } from "./message" -import type { Model } from "./model" +import { type Model, ProfileLookup } from "./model" -export const init = (userId: UserId): PageReturn<Model, Message> => ({ model: { userId, user: null } }) +export const init = (userId: UserId): PageReturn<Model, Message> => ({ + model: { userId, user: ProfileLookup.Loading() }, +}) export const update = (model: Model, message: Message): PageReturn<Model, Message> => Message.match<PageReturn<Model, Message>>(message, { - UpdatedProfileUser: ({ user }) => ({ model: modifyFields(model, { user: () => user }) }), + UpdatedProfileUser: ({ user }) => ({ + model: modifyFields(model, { + user: () => (user === null ? ProfileLookup.NotFound() : ProfileLookup.Found({ user })), + }), + }), }) diff --git a/apps/web-foldkit/src/page/profile/view.ts b/apps/web-foldkit/src/page/profile/view.ts index 36627697e..f20eaadb1 100644 --- a/apps/web-foldkit/src/page/profile/view.ts +++ b/apps/web-foldkit/src/page/profile/view.ts @@ -8,6 +8,7 @@ import { AppRoute, hrefOf } from "../../route" import { avatar } from "../../ui/avatar" import { buttonClassName } from "../../ui/button" import { inputGroup } from "../../ui/input" +import { loader } from "../../ui/loader" import { sectionHeaderGroup, sectionHeaderHeading, @@ -18,7 +19,7 @@ import { sectionLabelRoot } from "../../ui/section-label" import { textField } from "../../ui/text-field" import type { PageViewInputs } from "../contract" import type { Message } from "./message" -import type { Model, ProfileUser } from "./model" +import { type Model, ProfileLookup, type ProfileUser } from "./model" /** Port of `routes/_app/$orgSlug/profile/$userId.tsx`. */ @@ -28,6 +29,9 @@ const notFound = (h: HtmlBuilder<Message>) => [h.p([h.Class("text-muted-fg")], ["User not found"])], ) +const loading = (h: HtmlBuilder<Message>) => + h.div([h.Class("flex items-center justify-center p-8")], [loader(h, { className: "size-8" })]) + const profile = ( h: HtmlBuilder<Message>, user: ProfileUser, @@ -137,11 +141,14 @@ const profile = ( } export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => - model.user === null - ? notFound(h) - : profile(h, model.user, { + ProfileLookup.match(model.user, { + Loading: () => loading(h), + NotFound: () => notFound(h), + Found: ({ user }) => + profile(h, user, { isOwnProfile: shared.currentUser?.id === model.userId, orgSlug: shared.orgSlug, nowMs: shared.nowMs, }), + }), ) From d42b977e3808624a79e67050b81d1a8d4b2e5cca Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:24:34 +0200 Subject: [PATCH 29/52] fix(web): icons are decorative by default and icon-only controls are labelled Nucleo icons drop the legacy <title> (32 of them read "badge 13") and render aria-hidden; passing title gives role=img and a <title>. Every icon-only button the audit found now has an aria-label (sidebar row and section menus, image viewer toolbar, attachment remove, member Message, row menus, copy buttons, channel icon controls, command form Back). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/chat/image-viewer.ts | 15 +-- apps/web-foldkit/src/composer/draft-view.ts | 14 +-- apps/web-foldkit/src/icons/index.ts | 98 +++-------------- .../connect/share-modal-view.ts | 1 + .../integrations/provider-card.ts | 8 +- .../channel-settings/integrations/row-menu.ts | 7 +- .../integrations/token-display.ts | 1 + .../channel-settings/integrations/webhooks.ts | 1 + .../page/channel-settings/overview/view.ts | 3 +- .../page/chat/channel/messages.scene.test.ts | 2 +- apps/web-foldkit/src/page/home/scene.test.ts | 7 +- apps/web-foldkit/src/page/home/view.ts | 1 + .../src/page/settings/chat-sync/view.ts | 1 + .../src/page/settings/custom-emojis/view.ts | 1 + .../src/page/settings/invitations/view.ts | 18 +++- .../web-foldkit/src/shell/channels-sidebar.ts | 6 +- .../channels-sidebar/create-channel-hint.ts | 2 +- .../src/shell/channels-sidebar/items.ts | 4 +- .../src/shell/channels-sidebar/menus.ts | 32 +++++- .../src/shell/channels-sidebar/tree.ts | 12 ++- apps/web-foldkit/src/shell/mobile.ts | 2 +- apps/web-foldkit/src/ui/command-menu-form.ts | 2 + docs/foldkit-review/icons.md | 102 +++++++----------- 23 files changed, 149 insertions(+), 191 deletions(-) diff --git a/apps/web-foldkit/src/chat/image-viewer.ts b/apps/web-foldkit/src/chat/image-viewer.ts index 747dfd676..c9fd60868 100644 --- a/apps/web-foldkit/src/chat/image-viewer.ts +++ b/apps/web-foldkit/src/chat/image-viewer.ts @@ -140,7 +140,7 @@ const ImageViewerPortal = Mount.defineStream("ImageViewerPortal", { ), }) -const toolbarButton = <M>(h: HtmlBuilder<M>, onPress: M | undefined, icon: Html): Html => +const toolbarButton = <M>(h: HtmlBuilder<M>, 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, {}, [ button( @@ -150,6 +150,7 @@ const toolbarButton = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, inputs: ImageViewerInputs< const hasMany = images.length > 1 const act = (action: ImageViewerAction) => inputs.toAction && current ? inputs.toAction(action, current) : undefined - const actions: ReadonlyArray<readonly [M | undefined, Html]> = [ - [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<readonly [M | undefined, string, Html]> = [ + [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 = <M>(h: HtmlBuilder<M>, 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/composer/draft-view.ts b/apps/web-foldkit/src/composer/draft-view.ts index 26812b998..56a18a557 100644 --- a/apps/web-foldkit/src/composer/draft-view.ts +++ b/apps/web-foldkit/src/composer/draft-view.ts @@ -27,17 +27,17 @@ export interface DraftViewInputs<M> { readonly attachmentInfo: (id: AttachmentId) => { readonly fileName: string; readonly fileSize: number } | null } -const removeButton = <M>(h: HtmlBuilder<M>, onPress: M, label?: string) => +const removeButton = <M>(h: HtmlBuilder<M>, 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`. */ @@ -76,7 +76,7 @@ const attachmentPreviews = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftVie [ 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}`), ], ) }), @@ -129,7 +129,7 @@ const replyIndicator = <M>(h: HtmlBuilder<M>, 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), ], ) } @@ -148,7 +148,7 @@ const editIndicator = <M>(h: HtmlBuilder<M>, 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), ], ) } diff --git a/apps/web-foldkit/src/icons/index.ts b/apps/web-foldkit/src/icons/index.ts index fe5df2d2c..e0f46b4d3 100644 --- a/apps/web-foldkit/src/icons/index.ts +++ b/apps/web-foldkit/src/icons/index.ts @@ -1,4 +1,4 @@ -// Nucleo icons, originally generated from the React app's icon components. Edit by hand. +// Nucleo icons. Edit by hand. import type { Html, HtmlBuilder } from "foldkit/html" export interface IconOptions { @@ -6,7 +6,10 @@ export interface IconOptions { readonly className?: string /** Overrides (or with `null` removes) root attributes, like spreading props in the legacy component. */ readonly attributes?: Readonly<Record<string, string | null>> - /** Replaces the `<title>` text, like the legacy components' `title` prop. */ + /** + * Names the icon (`role="img"` plus a `<title>`). Without it the icon is decorative and hidden + * from assistive technology, so an icon-only control needs its own `aria-label`. + */ readonly title?: string } @@ -16,13 +19,18 @@ const icon = <Message>( options: IconOptions, children: Html[], ): Html => { - const attributes: Record<string, string | null> = { ...root, ...options.attributes } + const { title } = options + const attributes: Record<string, string | null> = { + ...root, + ...(title === undefined ? { "aria-hidden": "true" } : { role: "img" }), + ...options.attributes, + } if (options.className) attributes.class = options.className return h.svg( Object.entries(attributes) .filter((entry): entry is [string, string] => entry[1] !== null) .map(([key, value]) => (key === "class" ? h.Class(value) : h.Attribute(key, value))), - children, + title === undefined ? children : [h.title([], [title]), ...children], ) } @@ -38,7 +46,6 @@ export const IconArrowLeft = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "arrow-left"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -74,7 +81,6 @@ export const IconArrowPath = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "arrow-path"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -110,7 +116,6 @@ export const IconBell = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -156,7 +161,6 @@ export const IconBrainSparkle = <Message>(h: HtmlBuilder<Message>, options: Icon }, options, [ - h.title([], [options.title ?? "brain sparkle"]), h.g( [h.Attribute("fill", "none")], [ @@ -194,7 +198,6 @@ export const IconChartBar = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "chart-bar"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -254,7 +257,6 @@ export const IconChatBubble = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "chat-bubble"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -283,7 +285,6 @@ export const IconCheck = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "check"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -311,7 +312,6 @@ export const IconChevronDown = <Message>(h: HtmlBuilder<Message>, options: IconO }, options, [ - h.title([], [options.title ?? "chevron-down"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -339,7 +339,6 @@ export const IconChevronLeft = <Message>(h: HtmlBuilder<Message>, options: IconO }, options, [ - h.title([], [options.title ?? "chevron-left"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -367,7 +366,6 @@ export const IconChevronRight = <Message>(h: HtmlBuilder<Message>, options: Icon }, options, [ - h.title([], [options.title ?? "chevron-right"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -395,7 +393,6 @@ export const IconChevronUpDown = <Message>(h: HtmlBuilder<Message>, options: Ico }, options, [ - h.title([], [options.title ?? "chevron-up-down"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -431,7 +428,6 @@ export const IconCircleDottedUser = <Message>(h: HtmlBuilder<Message>, options: }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -563,7 +559,6 @@ export const IconCirclePause = <Message>(h: HtmlBuilder<Message>, options: IconO }, options, [ - h.title([], [options.title ?? "pause"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -605,7 +600,6 @@ export const IconCirclePlus = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -641,7 +635,6 @@ export const IconClock = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "clock"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -681,7 +674,6 @@ export const IconClose = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "close"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -716,7 +708,6 @@ export const IconCode = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "code"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -756,7 +747,6 @@ export const IconOffice = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "office"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -818,7 +808,6 @@ export const IconConnect = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "connect"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -870,7 +859,6 @@ export const IconCopy = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -908,7 +896,6 @@ export const IconCube = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "cube"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -951,7 +938,6 @@ export const IconDashboard = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1006,7 +992,6 @@ export const IconDiscord = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "Discord"]), h.path([ h.Attribute( "d", @@ -1029,7 +1014,6 @@ export const IconDotsVertical = <Message>(h: HtmlBuilder<Message>, options: Icon }, options, [ - h.title([], [options.title ?? "dots-vertical"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1068,7 +1052,6 @@ export const IconDots = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "dots"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1107,7 +1090,6 @@ export const IconDownload = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "download"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1143,7 +1125,6 @@ export const IconEdit = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1188,7 +1169,6 @@ export const IconEmoji1 = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1238,7 +1218,6 @@ export const IconEmojiAdd = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1297,7 +1276,6 @@ export const IconEnvelope = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1335,7 +1313,6 @@ export const IconEyeSlash = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "eye-slash"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1386,7 +1363,6 @@ export const IconEye = <Message>(h: HtmlBuilder<Message>, options: IconOptions = }, options, [ - h.title([], [options.title ?? "eye"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1422,7 +1398,6 @@ export const IconFolderPlus = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1466,7 +1441,6 @@ export const IconFolders = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "folder"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1502,7 +1476,6 @@ export const IconGear = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1538,7 +1511,6 @@ export const IconGif = <Message>(h: HtmlBuilder<Message>, options: IconOptions = }, options, [ - h.title([], [options.title ?? "GIF"]), h.g( [h.Attribute("fill", "none")], [ @@ -1582,7 +1554,6 @@ export const IconGridCirclePlus = <Message>(h: HtmlBuilder<Message>, options: Ic }, options, [ - h.title([], [options.title ?? "grid-circle-plus"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1691,7 +1662,6 @@ export const IconHeart = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "heart"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1720,7 +1690,6 @@ export const IconIntegratio = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1779,7 +1748,6 @@ export const IconLeave = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "leave"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1829,7 +1797,6 @@ export const IconLightbulb = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "lightbulb"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1910,7 +1877,6 @@ export const IconExternalLink = <Message>(h: HtmlBuilder<Message>, options: Icon }, options, [ - h.title([], [options.title ?? "external-link"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -1946,7 +1912,6 @@ export const IconLoader = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "loader"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2030,7 +1995,6 @@ export const IconLock = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2075,7 +2039,6 @@ export const IconLogout = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2111,7 +2074,6 @@ export const IconMagnifier3 = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2156,7 +2118,6 @@ export const IconMapPin = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "map-pin"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2208,7 +2169,6 @@ export const IconMegaphone = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "megaphone"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2255,7 +2215,6 @@ export const IconMenu = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "menu"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2297,7 +2256,6 @@ export const IconMinus = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "minus"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2325,7 +2283,6 @@ export const IconMoon = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "moon"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2356,7 +2313,6 @@ export const IconMsgs = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2393,7 +2349,6 @@ export const IconPaintbrush = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "paintbrush"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2431,7 +2386,6 @@ export const IconPaperclip2 = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2460,7 +2414,6 @@ export const IconPenSparkle = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "magic-wand"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2498,7 +2451,6 @@ export const IconProfiles2 = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "#ffffff")], [ @@ -2554,7 +2506,6 @@ export const IconPhone = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2583,7 +2534,6 @@ export const IconPin = <Message>(h: HtmlBuilder<Message>, options: IconOptions = }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2619,7 +2569,6 @@ export const IconPlay = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "play"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2659,7 +2608,6 @@ export const IconPlus = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2696,7 +2644,6 @@ export const IconPresentationChart = <Message>(h: HtmlBuilder<Message>, options: }, options, [ - h.title([], [options.title ?? "presentation-chart"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2748,7 +2695,6 @@ export const IconReply = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2784,7 +2730,6 @@ export const IconRobot = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "robot"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2845,7 +2790,6 @@ export const IconRocket = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "rocket"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2906,7 +2850,6 @@ export const IconServers = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2961,7 +2904,6 @@ export const IconShare = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -2997,7 +2939,6 @@ export const IconShop = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "shop"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3053,7 +2994,6 @@ export const IconSparkles = <Message>(h: HtmlBuilder<Message>, options: IconOpti }, options, [ - h.title([], [options.title ?? "sparkles"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3136,7 +3076,6 @@ export const IconSun = <Message>(h: HtmlBuilder<Message>, options: IconOptions = }, options, [ - h.title([], [options.title ?? "sun"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3237,7 +3176,6 @@ export const IconSupport = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3299,7 +3237,6 @@ export const IconThread = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3352,7 +3289,6 @@ export const IconTrash = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3402,7 +3338,6 @@ export const IconUnpin = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3454,7 +3389,6 @@ export const IconUser = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "user"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3490,7 +3424,6 @@ export const IconUsersPlus = <Message>(h: HtmlBuilder<Message>, options: IconOpt }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3545,7 +3478,6 @@ export const IconUsers = <Message>(h: HtmlBuilder<Message>, options: IconOptions }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3597,7 +3529,6 @@ export const IconVolumeMute = <Message>(h: HtmlBuilder<Message>, options: IconOp }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3634,7 +3565,6 @@ export const IconVolume = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "badge 13"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3664,7 +3594,6 @@ export const IconWarning = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "warning"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3700,7 +3629,6 @@ export const IconWebhook = <Message>(h: HtmlBuilder<Message>, options: IconOptio }, options, [ - h.title([], [options.title ?? "webhook"]), h.g( [h.Attribute("fill", "currentColor")], [ @@ -3822,7 +3750,6 @@ export const IconGitHub = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "GitHub"]), h.path([ h.Attribute( "d", @@ -3844,7 +3771,6 @@ export const IconGoogle = <Message>(h: HtmlBuilder<Message>, options: IconOption }, options, [ - h.title([], [options.title ?? "Google"]), h.path([ h.Attribute( "d", diff --git a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts index 07434ac96..b443424b1 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts @@ -71,6 +71,7 @@ const selectedChip = (h: HtmlBuilder<Message>, workspace: Workspace): Html => [ h.Type("button"), h.OnClick(Message.ClickedClearWorkspace()), + h.AriaLabel("Clear workspace"), h.Class("ml-auto text-muted-fg hover:text-fg"), ], [IconClose(h, { className: "size-4" })], diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts b/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts index bb13e692e..841659dc8 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts @@ -115,6 +115,7 @@ export const providerCard = ( successMessage: "URL copied", failureMessage: "Failed to copy", }), + attributes: [h.AriaLabel("Copy URL")], }, [copyIcon(h, isCopied)], ), @@ -259,7 +260,12 @@ export const providerCard = ( ), button( h, - { intent: "outline", size: "sq-sm", onPress: Message.ClickedProviderUrlInfo() }, + { + intent: "outline", + size: "sq-sm", + onPress: Message.ClickedProviderUrlInfo(), + attributes: [h.AriaLabel("URL info")], + }, [IconCopy(h, { className: "size-4" })], ), ], diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts b/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts index 6842fbd5c..f3bdf6f62 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/row-menu.ts @@ -46,7 +46,12 @@ export const rowMenu = ( toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => button( h, - { intent: "plain", size: "sq-xs", className: options.triggerClassName, attributes }, + { + intent: "plain", + size: "sq-xs", + className: options.triggerClassName, + attributes: [h.AriaLabel("Subscription actions"), ...attributes], + }, [IconDotsVertical(h, { className: "size-4" }), overlay], ), content, diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/token-display.ts b/apps/web-foldkit/src/page/channel-settings/integrations/token-display.ts index 4cfa74877..eb022523a 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/token-display.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/token-display.ts @@ -32,6 +32,7 @@ const copyButton = (h: HtmlBuilder<Message>, model: Model, id: "token" | "url", successMessage: id === "token" ? "Token copied" : "URL copied", failureMessage: "Failed to copy to clipboard", }), + attributes: [h.AriaLabel(id === "token" ? "Copy token" : "Copy URL")], }, [copyIcon(h, model.copiedIds.includes(id))], ) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts index 17061c659..6fa802cff 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts @@ -90,6 +90,7 @@ const compactWebhookItem = ( successMessage: "URL copied", failureMessage: "Failed to copy", }), + attributes: [h.AriaLabel("Copy webhook URL")], }, [copyIcon(h, model.copiedIds.includes(webhook.id))], ), diff --git a/apps/web-foldkit/src/page/channel-settings/overview/view.ts b/apps/web-foldkit/src/page/channel-settings/overview/view.ts index 3057c422a..d62662d40 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/view.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/view.ts @@ -30,7 +30,7 @@ const iconField = (h: HtmlBuilder<Message>, form: Form): Html => intent: "outline", size: "sq-md", className: "text-xl", - attributes: [h.Attribute("aria-expanded", "false")], + attributes: [h.AriaLabel("Change channel icon"), h.Attribute("aria-expanded", "false")], }, [channelIcon(h, form.icon)], ), @@ -43,6 +43,7 @@ const iconField = (h: HtmlBuilder<Message>, form: Form): Html => size: "sq-sm", className: "text-muted-fg hover:text-fg", onPress: Message.ClearedIcon(), + attributes: [h.AriaLabel("Remove channel icon")], }, [IconClose(h, { className: "size-4" })], ), diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 2e1f750f3..79cdbb313 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -192,7 +192,7 @@ describe("image viewer", () => { Scene.click(Scene.altText("photo-1.png")), viewerMounted(0), // Legacy nests the action Button inside its bare `TooltipTrigger` Button; the inner one acts. - Scene.click(Scene.within(Scene.role("button", { name: "download" }), Scene.selector("button button"))), + Scene.click(Scene.selector(`button[aria-label="Download"]`)), Scene.Command.expectExact(DownloadImage({ url: "https://cdn.hazel.sh/photo-1.png", fileName: "photo-1.png" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ diff --git a/apps/web-foldkit/src/page/home/scene.test.ts b/apps/web-foldkit/src/page/home/scene.test.ts index 9ad023b34..d70c6eea6 100644 --- a/apps/web-foldkit/src/page/home/scene.test.ts +++ b/apps/web-foldkit/src/page/home/scene.test.ts @@ -38,7 +38,7 @@ const loadedMembers = [ Scene.Mount.resolve(FocusTriggerOnPress, Menu.Message.CompletedFocusTriggerOnPress()), ] // The Message button is icon-only with no accessible name, so it is reached by CSS. -const messageButton = Scene.selector("button:not([aria-label])") +const messageButton = Scene.role("button", { name: "Message Grace Hopper" }) describe("member directory", () => { test("shows a loader until the members arrive, then lists them", () => { @@ -107,14 +107,13 @@ describe("member directory", () => { ) }) - // A11y bug: the icon-only Message button has no accessible name (legacy had none either). - test.fails("the Message button is named", () => { + test("the icon-only Message button is named after the member", () => { Scene.scene( pageScene(update, view, shared), Scene.given(init().model), focused, ...loadedMembers, - Scene.expect(Scene.role("button", { name: /Message/ })).toExist(), + Scene.expect(messageButton).toExist(), ) }) }) diff --git a/apps/web-foldkit/src/page/home/view.ts b/apps/web-foldkit/src/page/home/view.ts index 97273d88a..300cebbc7 100644 --- a/apps/web-foldkit/src/page/home/view.ts +++ b/apps/web-foldkit/src/page/home/view.ts @@ -167,6 +167,7 @@ const memberRow = ( }), className: "hidden border-transparent data-pressed:bg-muted group-hover:border-border sm:inline-flex", + attributes: [h.AriaLabel(`Message ${fullName}`)], }, [IconMsgs(h, { attributes: { "data-slot": "icon" } })], ), diff --git a/apps/web-foldkit/src/page/settings/chat-sync/view.ts b/apps/web-foldkit/src/page/settings/chat-sync/view.ts index 32b87ee76..663aa2363 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/view.ts @@ -189,6 +189,7 @@ const connectionCard = (h: HtmlBuilder<Message>, connection: Connection): Html = "rounded-sm p-1 text-muted-fg opacity-0 transition-all hover:bg-danger/10 hover:text-danger group-hover:opacity-100", ), h.Title("Delete connection"), + h.AriaLabel("Delete connection"), h.Type("button"), h.OnClick( Message.ClickedDeleteConnection({ diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts index 89ab99a57..df42cb030 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts @@ -268,6 +268,7 @@ const emojiRow = (h: HtmlBuilder<Message>, emoji: Emoji, isAdmin: boolean, nowMs intent: "danger", size: "sq-sm", onPress: Message.ClickedDeleteEmoji({ id: emoji.id, name: emoji.name }), + attributes: [h.AriaLabel(`Delete :${emoji.name}:`)], }, [IconTrash(h, { attributes: { "data-slot": "icon" } })], ), diff --git a/apps/web-foldkit/src/page/settings/invitations/view.ts b/apps/web-foldkit/src/page/settings/invitations/view.ts index 64ac99de1..b5c10ee85 100644 --- a/apps/web-foldkit/src/page/settings/invitations/view.ts +++ b/apps/web-foldkit/src/page/settings/invitations/view.ts @@ -37,10 +37,20 @@ const rowActions = (h: HtmlBuilder<Message>, row: RowMenu, isRevoking: boolean): viewInputs: { entries: rowMenuEntries(isRevoking), toTrigger: (attributes, overlay) => - button(h, { intent: "plain", size: "sq-xs", isPending: isRevoking, isDisabled: isRevoking, attributes }, [ - IconDots(h), - overlay, - ]), + button( + h, + { + intent: "plain", + size: "sq-xs", + isPending: isRevoking, + isDisabled: isRevoking, + attributes: [h.AriaLabel("Invitation actions"), ...attributes], + }, + [ + IconDots(h), + overlay, + ], + ), content: (key) => [ IconClose(h, { attributes: { "data-slot": "icon" } }), menuLabel(h, row.menu.id, key, isRevoking ? "Revoking..." : "Revoke Invitation"), diff --git a/apps/web-foldkit/src/shell/channels-sidebar.ts b/apps/web-foldkit/src/shell/channels-sidebar.ts index daf9214ad..b64217c34 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar.ts @@ -20,7 +20,7 @@ import { updateRowMenu, updateSectionMenu, } from "./channels-sidebar/menu-update" -import { sectionMenuEntries, sectionMenuView } from "./channels-sidebar/menus" +import { sectionActionLabel, sectionMenuEntries, sectionMenuView } from "./channels-sidebar/menus" import { dragDescription, sectionPlusButton } from "./channels-sidebar/tree" import { sectionGroupContent } from "./channels-sidebar/view" @@ -256,7 +256,9 @@ const sectionActionView = <ParentMessage>( const { actions, isEditable } = sectionActionsOf(model, sectionKey) const [only] = actions return only !== undefined && actions.length === 1 && !isEditable - ? sectionPlusButton(h, [h.OnClick(toParentMessage(Message.ClickedSectionAction({ action: only })))]) + ? sectionPlusButton(h, sectionActionLabel(only), [ + h.OnClick(toParentMessage(Message.ClickedSectionAction({ action: only }))), + ]) : sectionMenuView(h, { menu: sectionMenuOf(model, sectionKey), entries: sectionMenuEntries(actions, isEditable), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts index 02c1f1dbf..bc3e41904 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts @@ -29,7 +29,7 @@ export const createChannelHint = <M>(h: HtmlBuilder<M>, onDismiss: Attribute<M>) ), ], ), - button(h, { intent: "plain", size: "sq-xs", className: "size-5", attributes: [onDismiss] }, [ + button(h, { intent: "plain", size: "sq-xs", className: "size-5", attributes: [h.AriaLabel("Dismiss"), onDismiss] }, [ IconClose(h, { className: "size-3" }), ]), ], diff --git a/apps/web-foldkit/src/shell/channels-sidebar/items.ts b/apps/web-foldkit/src/shell/channels-sidebar/items.ts index b08071232..f98473ca2 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/items.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/items.ts @@ -119,7 +119,7 @@ export const channelItem = <M>( notificationCount: number, partners: ReadonlyArray<PartnerOrg>, context: RowLinkContext, - menu: Html = dotsMenuTrigger(h), + menu: Html = dotsMenuTrigger(h, "Channel options"), ): Html => { const href = chatHref(context, entry.channel.id) return sidebarItem(h, {}, [ @@ -304,7 +304,7 @@ export const dmItem = <M>( ? singleDmContent(h, channel, only, context) : groupDmContent(h, channel, partners), ), - dotsMenuTrigger(h), + dotsMenuTrigger(h, "Conversation options"), ...badgeIf(h, notificationCount), ]) } diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts index 4a2d2934d..beffc262d 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts @@ -138,7 +138,15 @@ export const rowMenuView = <M>( intent: "plain", size: "sq-xs", className: "size-5 text-muted-fg", - attributes: [h.Attribute("data-slot", "menu-trigger"), ...attributes], + attributes: [ + h.Attribute("data-slot", "menu-trigger"), + h.AriaLabel( + options.entry.channel.type === "single" || options.entry.channel.type === "direct" + ? "Conversation options" + : "Channel options", + ), + ...attributes, + ], }, [IconDots(h, { className: "size-4" }), overlay], ), @@ -150,6 +158,14 @@ export const rowMenuView = <M>( toParentMessage: options.toMessage, }) +/** The accessible name of a section's single-action "+" button (the same words as its menu item). */ +export const sectionActionLabel = (action: SectionAction): string => + action === "create-channel" + ? "Create new channel" + : action === "join-channel" + ? "Join existing channel" + : "Start a conversation" + const sectionMenuContent = <M>(h: HtmlBuilder<M>, id: string) => (key: string): ReadonlyArray<Html | string> => { @@ -177,10 +193,16 @@ export const sectionMenuView = <M>( viewInputs: { entries: options.entries, toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => - button(h, { intent: "plain", isCircle: true, size: "sq-xs", attributes: [...attributes] }, [ - IconPlus(h), - overlay, - ]), + button( + h, + { + intent: "plain", + isCircle: true, + size: "sq-xs", + attributes: [h.AriaLabel("Section actions"), ...attributes], + }, + [IconPlus(h), overlay], + ), content: sectionMenuContent(h, options.menu.id), }, toParentMessage: options.toMessage, diff --git a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts index 68bd3a4dc..cdf27972d 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts @@ -22,27 +22,31 @@ const menuTriggerAttributes = <M>(h: HtmlBuilder<M>) => [ ] /** The row actions trigger in channel and DM items. */ -export const dotsMenuTrigger = <M>(h: HtmlBuilder<M>): Html => +export const dotsMenuTrigger = <M>(h: HtmlBuilder<M>, label: string): Html => button( h, { intent: "plain", size: "sq-xs", className: "size-5 text-muted-fg", - attributes: [h.Attribute("data-slot", "menu-trigger"), ...menuTriggerAttributes(h)], + attributes: [h.Attribute("data-slot", "menu-trigger"), h.AriaLabel(label), ...menuTriggerAttributes(h)], }, [IconDots(h, { className: "size-4" })], ) /** `SectionGroup`'s single-action "+" button (no menu). */ -export const sectionPlusButton = <M>(h: HtmlBuilder<M>, attributes: ReadonlyArray<Attribute<M>>): Html => +export const sectionPlusButton = <M>( + h: HtmlBuilder<M>, + label: string, + attributes: ReadonlyArray<Attribute<M>>, +): Html => button( h, { intent: "plain", isCircle: true, size: "sq-xs", - attributes: [...attributes], + attributes: [h.AriaLabel(label), ...attributes], }, [IconPlus(h)], ) diff --git a/apps/web-foldkit/src/shell/mobile.ts b/apps/web-foldkit/src/shell/mobile.ts index ee2126d42..2aea00ffe 100644 --- a/apps/web-foldkit/src/shell/mobile.ts +++ b/apps/web-foldkit/src/shell/mobile.ts @@ -104,7 +104,7 @@ export const mobileMenuButton = <Message>( intent: "plain", size: "sq-xs", className: twMerge("-ml-1 md:hidden", options.className), - attributes: [options.onPress], + attributes: [h.AriaLabel("Open sidebar"), options.onPress], }, [IconMenu(h, { className: "size-5" })], ) diff --git a/apps/web-foldkit/src/ui/command-menu-form.ts b/apps/web-foldkit/src/ui/command-menu-form.ts index 2f258ba3d..7573a4fdb 100644 --- a/apps/web-foldkit/src/ui/command-menu-form.ts +++ b/apps/web-foldkit/src/ui/command-menu-form.ts @@ -54,6 +54,7 @@ export const commandMenuFormHeader = <M>( h.button( [ ...options.backAttributes, + h.AriaLabel("Back"), h.Class(commandMenuFormBackClassName), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("tabindex", "0"), @@ -63,6 +64,7 @@ export const commandMenuFormHeader = <M>( h.svg( [ h.Attribute("xmlns", "http://www.w3.org/2000/svg"), + h.AriaHidden(true), h.Attribute("viewBox", "0 0 16 16"), h.Attribute("fill", "currentColor"), h.Class(commandMenuFormBackIconClassName), diff --git a/docs/foldkit-review/icons.md b/docs/foldkit-review/icons.md index a50576fca..5cb710f5d 100644 --- a/docs/foldkit-review/icons.md +++ b/docs/foldkit-review/icons.md @@ -1,64 +1,38 @@ -# Foldkit review: icon accessibility (post-cutover proposal) - -Status: proposal. Apply to `apps/web-foldkit` only after the cutover, when legacy `apps/web` is -retired. Until then Foldkit must keep legacy's ARIA tree, because `parity run --strict-a11y` fails -on any difference. - -## Today (matches legacy on purpose) - -- Every Nucleo icon (`src/icons/index.ts`, generated from `apps/web/src/components/icons`) renders - a `<title>` and no `aria-hidden`, so the svg is exposed as `img "<title>"`. 35 of - the 90 titled icons are titled `badge 13` (IconStar and IconHashtag have no title); the rest use - the file name (`chevron-down`, `dots`, `download`). -- The title text joins the accessible name of its button or link: "chevron-down Channels", - "badge 13 Browse channels", "Hazel Labs chevron-up-down", and an icon-only button is named after - its icon alone ("badge 13", "dots", "close"). -- Heroicons ports (calendar, date picker, tree chevron), the loader ring, video player icons, - file-type icons and the live-message icons are already `aria-hidden`, like legacy. - -The audit for this note (2026-10-09) found no Foldkit difference from legacy in icon titles, -`aria-hidden`, `title`, or icon-only button labels on the ported surfaces. Legacy surfaces that are -not ported (agent steps view, mobile date picker modal, Maple/Railway/OpenStatus config modals and -sections, the unused `AppNav`/`SidebarTrigger`/`UserStatusBadge`) were left out. - -## Proposal - -1. **Decorative by default.** In `scripts/generate-icons.ts`, stop emitting `<title>` and add - `aria-hidden="true"` to the root, unless the call passes `title`. When `title` is passed, render - `<title>` and `role="img"` and drop `aria-hidden`. Only the field validation state (`Valid`, - `Validating...`) passes a title today, and it is meaningful. -2. **Every icon-only control gets an `aria-label`.** Once (1) lands these buttons have no name at - all, so they must ship in the same change. The `button` helper could require `ariaLabel` when - its only child is an icon, but a scene test sweep (`getByRole("button", { name })`) is enough. -3. **Icon-plus-text controls need nothing.** Their text becomes the whole name ("Channels", - "Browse channels", "Hazel Labs"). -4. Update scene tests and parity scenarios that locate controls by icon title (`"dots"`, - `"badge 13"`, `"close"`), then re-baseline the ARIA snapshots in the same change. - -## Icon-only buttons without a label today - -| Where | Icon (name today) | Proposed `aria-label` | -| --- | --- | --- | -| `shell/channels-sidebar/tree.ts` `dotsMenuTrigger` (channel and DM rows) | dots | `Channel options` / `Conversation options` | -| `shell/channels-sidebar/menus.ts:138` (thread rows) | dots | `Thread options` | -| `shell/channels-sidebar/tree.ts` `sectionPlusButton` and the section "+" menu | plus | `Add channel` (Channels), `New message` (Direct Messages) | -| `shell/channels-sidebar/create-channel-hint.ts:32` | close | `Dismiss` | -| `shell/mobile.ts:104` mobile sidebar trigger | menu | `Open sidebar` | -| `chat/image-viewer.ts:187-190` toolbar | download, badge 13, external-link, close | `Download`, `Copy image URL`, `Open in browser`, `Close` | -| `composer/draft-view.ts` `removeButton` (attachment previews) | close | `Remove <file name>` | -| `page/home/view.ts:173` member row | badge 13 | `Message <name>` | -| `page/settings/invitations/view.ts:38` row menu | dots | `Invitation actions` | -| `page/settings/custom-emojis/view.ts:275` | badge 13 | `Delete :<name>:` | -| `page/settings/chat-sync/view.ts:202` (has `title`) | badge 13 | `Delete connection` | -| `page/channel-settings/overview/view.ts:45` icon picker | channel icon | `Change channel icon` | -| `page/channel-settings/overview/view.ts:47` | close | `Remove channel icon` | -| `page/channel-settings/connect/share-modal-view.ts:76` | close | `Clear workspace` | -| `page/channel-settings/integrations/row-menu.ts:42` | dots | `Subscription actions` | -| `page/channel-settings/integrations/webhooks.ts:87` | badge 13 / check | `Copy webhook URL` | -| `page/channel-settings/integrations/token-display.ts:29` | badge 13 / check | `Copy token`, `Copy URL` | -| `page/channel-settings/integrations/provider-card.ts:112` | badge 13 / check | `Copy URL` | -| `page/channel-settings/integrations/provider-card.ts:263` | badge 13 | `URL info` (it only toasts "Delete and reconnect to get a new URL") | - -Buttons that already carry a label keep it ("Add reaction", "More actions", "Call", "Close thread", -"Remove invite", "Clear search", "Previous image", ...). With (1) their icon child stops adding an -`img` line to the tree. +# Foldkit review: icon accessibility + +Status: applied (2026-10-15), after the legacy React app and the parity harness were deleted. + +## Rules + +- **Decorative by default.** A Nucleo icon (`apps/web-foldkit/src/icons/index.ts`) renders + `aria-hidden="true"` and no `<title>`. Passing `title` names it instead: the svg gets `role="img"` + and a `<title>`. The 32 legacy `badge 13` titles and the file-name titles (`dots`, `close`, + `chevron-down`, ...) are gone. +- **Every icon-only control has an `aria-label`.** Icon-plus-text controls need nothing; their text + is the whole name ("Channels", "Browse channels", "Hazel Labs"). + +## Icon-only controls labelled in this change + +| Where | Label | +| --- | --- | +| `shell/channels-sidebar/tree.ts` `dotsMenuTrigger`, `menus.ts` row menu | `Channel options` / `Conversation options` | +| `shell/channels-sidebar/tree.ts` `sectionPlusButton` | the action's words (`Create new channel`, `Join existing channel`, `Start a conversation`) | +| `shell/channels-sidebar/menus.ts` section "+" menu | `Section actions` | +| `shell/channels-sidebar/create-channel-hint.ts` | `Dismiss` | +| `shell/mobile.ts` `mobileMenuButton` | `Open sidebar` | +| `chat/image-viewer.ts` toolbar | `Download`, `Copy image URL`, `Open in browser`, `Close` | +| `composer/draft-view.ts` attachment preview | `Remove <file name>` | +| `page/home/view.ts` member row | `Message <name>` | +| `page/settings/invitations/view.ts` row menu | `Invitation actions` | +| `page/settings/custom-emojis/view.ts` | `Delete :<name>:` | +| `page/settings/chat-sync/view.ts` | `Delete connection` | +| `page/channel-settings/overview/view.ts` | `Change channel icon`, `Remove channel icon` | +| `page/channel-settings/connect/share-modal-view.ts` | `Clear workspace` | +| `page/channel-settings/integrations/row-menu.ts` | `Subscription actions` | +| `page/channel-settings/integrations/webhooks.ts` | `Copy webhook URL` | +| `page/channel-settings/integrations/token-display.ts` | `Copy token`, `Copy URL` | +| `page/channel-settings/integrations/provider-card.ts` | `Copy URL`, `URL info` | +| `ui/command-menu-form.ts` back button | `Back` | + +Buttons that already carried a label keep it ("Add reaction", "More actions", "Call", "Close thread", +"Remove invite", "Clear search", "Previous image", ...). From b5cc0d85c5ef49847584c888fdc5615058a75be2 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:24:55 +0200 Subject: [PATCH 30/52] docs: record the end of React Aria DOM parity in the S5 decision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/foldkit-decisions/s5-primitives.md | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/foldkit-decisions/s5-primitives.md b/docs/foldkit-decisions/s5-primitives.md index 42cb4869b..0e7d44d19 100644 --- a/docs/foldkit-decisions/s5-primitives.md +++ b/docs/foldkit-decisions/s5-primitives.md @@ -1,6 +1,30 @@ # S5: Primitive parity -Status: **passed** · 2026-10-07 · Branches `fk/kit-forms`, `fk/kit-overlays`, `fk/kit-data` (merged) +Status: **passed** · 2026-10-07 · Branches `fk/kit-forms`, `fk/kit-overlays`, `fk/kit-data` (merged) · **Superseded 2026-10-15** (see below) + +## Since then (2026-10-15) + +The legacy React app is deleted, and with it the reason for pixel and DOM parity. The parity harness +(`packages/ui-parity`), the `/dev/gallery` routes and their entries, and the `legacy-ui-guard` agent are +gone. The kit no longer emulates React Aria's DOM: + +- No `data-rac`, no React Aria default class names, and no `tailwindcss-react-aria-components` plugin. + Hover, focus and focus-visible are native CSS (`hover:`, `focus-visible:`, `group-hover:`, + `group-has-focus-visible:` for controls whose input is visually hidden). State CSS cannot see uses + Tailwind's data variants (`data-selected:`, `data-pressed:`, `data-disabled:`, `data-invalid:`, + `data-open:`, `data-[placement=...]:`, `data-[orientation=...]:`). +- Switch, Checkbox and RadioGroup need no interaction wiring for their hover and focus ring. +- Slider and Tabs are `@foldkit/ui` 0.167 Submodels with styled `toView`s; their parents own the value. +- Menu, Select and ComboBox keep only popup and selection state; the parent passes entries/items as + view inputs (and, for ComboBox, to `update`). +- Gallery-only primitives (ListBox, Table, Tree, Toggle, ToggleGroup, InputOTP, Text, SectionFooter, + FormErrorSummary, FieldValidationState) and Calendar's Range mode are deleted. +- Icons are decorative (`aria-hidden`) unless given a title; icon-only controls carry `aria-label`s + (`docs/foldkit-review/icons.md`). + +The overlay primitives (Modal, Sheet, Popover, Tooltip, Toast) still use the vendored positioning and +the module-level overlay stack described below; moving them to `@foldkit/ui` Dialog/Popover/Tooltip and +Animation is the next step. The rest of this record describes the parity-era design. Question: do `@foldkit/ui` plus the shared stylesheet reach pixel identity with the React Aria primitives, in both themes, including interaction states? From 0ea49530e8a198dea0b9c8c73d47c5c83e69613d Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:25:37 +0200 Subject: [PATCH 31/52] fix(web): message list skips ApplyScroll when its element is gone; ScrollPhase union A hidden list (Files tab) answered ApplyScroll with scrollTop 0, which re-anchored the list at its top row and made the page load older pages until the channel's start. SkippedApplyScroll marks the version applied and leaves scrollTop and the anchor alone. isScrolling, isSettleWaitPending and the rendered window keys become one ScrollPhase union. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/mount/message-list.ts | 166 +++++++++++++-------- 1 file changed, 102 insertions(+), 64 deletions(-) diff --git a/apps/web-foldkit/src/mount/message-list.ts b/apps/web-foldkit/src/mount/message-list.ts index 880945d57..a489939e1 100644 --- a/apps/web-foldkit/src/mount/message-list.ts +++ b/apps/web-foldkit/src/mount/message-list.ts @@ -25,6 +25,24 @@ export const ViewportAnchor = defineTaggedUnion({ }) export type ViewportAnchor = typeof ViewportAnchor.Type +/** + * While the reader scrolls, rows render from a wide overscan window in row order instead of the + * pool (LegendList's 300px draw distance inserts a row subtree nearly every frame, and each insertion + * restyles the page). The pool keeps running and takes over again once the scroll settles. + * `Scrolling` always has exactly one `WaitForScrollSettle` in flight; `eventVersion` is the latest + * scroll event, and the overscan window's first and last row move in chunks, so most scroll frames + * change no DOM. + */ +export const ScrollPhase = defineTaggedUnion({ + Settled: {}, + Scrolling: { + eventVersion: Schema.Number, + renderedFromKey: Schema.NullOr(Schema.String), + renderedToKey: Schema.NullOr(Schema.String), + }, +}) +export type ScrollPhase = typeof ScrollPhase.Type + export const Model = Schema.Struct({ id: Schema.String, estimatedRowHeightPx: Schema.Number, @@ -43,17 +61,7 @@ export const Model = Schema.Struct({ appliedScrollVersion: Schema.Number, /** LegendList's containers: which row each rendered `<div>` holds, in DOM order. */ pool: Pool, - /** - * While the reader scrolls, rows render from a wide overscan window in row order instead (LegendList's - * 300px draw distance inserts a row subtree nearly every frame, and each insertion restyles the - * page). The pool keeps running and takes over again once the scroll settles. - */ - isScrolling: Schema.Boolean, - scrollEventVersion: Schema.Number, - isSettleWaitPending: Schema.Boolean, - /** First and last row of the overscan window. Moved in chunks, so most scroll frames change no DOM. */ - renderedFromKey: Schema.NullOr(Schema.String), - renderedToKey: Schema.NullOr(Schema.String), + scrollPhase: ScrollPhase, /** `activeStickyIndex`: the divider pinned `position: sticky`. */ activeStickyKey: Schema.NullOr(Schema.String), /** @@ -75,6 +83,8 @@ export const Message = defineMessageUnion({ measurements: Schema.Array(Schema.Struct({ key: Schema.String, height: Schema.Number })), }, CompletedApplyScroll: { version: Schema.Number, scrollTop: Schema.Number }, + /** The list element was gone (another tab is shown): nothing was scrolled, nothing was observed. */ + SkippedApplyScroll: { version: Schema.Number }, CompletedWaitForScrollSettle: { version: Schema.Number }, }) export type Message = typeof Message.Type @@ -103,11 +113,7 @@ export const init = (config: { scrollVersion: 0, appliedScrollVersion: 0, pool: emptyPool, - isScrolling: false, - scrollEventVersion: 0, - isSettleWaitPending: false, - renderedFromKey: null, - renderedToKey: null, + scrollPhase: ScrollPhase.Settled(), activeStickyKey: null, stuckKey: null, }) @@ -133,7 +139,10 @@ const computeLayout = (model: Model): Layout => { } // Pure memos keyed by the frozen inputs, so lists never evict each other's entries. -const layouts = new WeakMap<ReadonlyArray<string>, WeakMap<object, { readonly estimate: number; readonly layout: Layout }>>() +const layouts = new WeakMap< + ReadonlyArray<string>, + WeakMap<object, { readonly estimate: number; readonly layout: Layout }> +>() /** Prefix sums over measured or estimated heights, memoized on the (frozen) inputs by reference. */ export const layoutOf = (model: Model): Layout => { @@ -227,12 +236,12 @@ const afterLayout = (element: Element) => /** Adjusts `scrollTop` once the pending patch has committed and laid out, before the browser paints. */ export const ApplyScroll = Command.define("ApplyScroll", { args: { id: Schema.String, adjustment: ScrollAdjustment, version: Schema.Number }, - messages: [Message.CompletedApplyScroll], + messages: [Message.CompletedApplyScroll, Message.SkippedApplyScroll], execute: ({ id, adjustment, version }) => Effect.gen(function* () { yield* Render.afterCommit const element = document.getElementById(id) - if (element === null) return Message.CompletedApplyScroll({ version, scrollTop: 0 }) + if (element === null) return Message.SkippedApplyScroll({ version }) yield* afterLayout(element) element.scrollTop = adjustment._tag === "To" ? adjustment.scrollTop : element.scrollTop + adjustment.deltaPx @@ -288,13 +297,16 @@ const RENDER_STEP_PX = 320 const RENDER_SLACK_PX = 600 export const renderedIndexes = (model: Model, layout: Layout) => { - const from = model.renderedFromKey === null ? undefined : layout.indexByKey.get(model.renderedFromKey) - const to = model.renderedToKey === null ? undefined : layout.indexByKey.get(model.renderedToKey) + const phase = model.scrollPhase + if (phase._tag === "Settled") return undefined + const from = phase.renderedFromKey === null ? undefined : layout.indexByKey.get(phase.renderedFromKey) + const to = phase.renderedToKey === null ? undefined : layout.indexByKey.get(phase.renderedToKey) return from === undefined || to === undefined || from > to ? undefined : { from, to } } const keepOverscan = (model: Model): Model => { - if (!model.isScrolling || model.viewportHeight === 0 || model.keys.length === 0) return model + const phase = model.scrollPhase + if (phase._tag === "Settled" || model.viewportHeight === 0 || model.keys.length === 0) return model const layout = layoutOf(model) const top = model.scrollTop const bottom = top + model.viewportHeight @@ -314,27 +326,37 @@ const keepOverscan = (model: Model): Model => { at(bottom + RENDER_LEAD_PX), Math.max(coveredTo, at(layout.offsets[coveredTo + 1]! + RENDER_STEP_PX)), ) - const from = Math.max( - Math.min(coveredFrom, grownFrom), - at(top - RENDER_LEAD_PX - RENDER_SLACK_PX), - ) + const from = Math.max(Math.min(coveredFrom, grownFrom), at(top - RENDER_LEAD_PX - RENDER_SLACK_PX)) const to = Math.min(Math.max(coveredTo, grownTo), at(bottom + RENDER_LEAD_PX + RENDER_SLACK_PX)) if (current !== undefined && from === current.from && to === current.to) return model - return modifyFields(model, { renderedFromKey: () => model.keys[from]!, renderedToKey: () => model.keys[to]! }) + return modifyFields(model, { + scrollPhase: () => + ScrollPhase.Scrolling({ + eventVersion: phase.eventVersion, + renderedFromKey: model.keys[from]!, + renderedToKey: model.keys[to]!, + }), + }) } /** A scroll event that is not the echo of our own `ApplyScroll`: the reader is scrolling. */ -const startedScrolling = (model: Model): ListReturn => { - const version = Number.increment(model.scrollEventVersion) - const scrolling = modifyFields(model, { - isScrolling: () => true, - scrollEventVersion: () => version, - isSettleWaitPending: () => true, +const startedScrolling = (model: Model): ListReturn => + ScrollPhase.match(model.scrollPhase, { + // The settle wait in flight re-arms itself for the newest event when it completes. + Scrolling: (phase) => ({ + model: modifyFields(model, { + scrollPhase: () => + ScrollPhase.Scrolling({ ...phase, eventVersion: Number.increment(phase.eventVersion) }), + }), + }), + Settled: () => ({ + model: modifyFields(model, { + scrollPhase: () => + ScrollPhase.Scrolling({ eventVersion: 1, renderedFromKey: null, renderedToKey: null }), + }), + commands: [WaitForScrollSettle({ version: 1 })], + }), }) - return model.isSettleWaitPending - ? { model: scrolling } - : { model: scrolling, commands: [WaitForScrollSettle({ version })] } -} const stickyIndexes = new WeakMap<ReadonlyArray<string>, WeakMap<Layout, ReadonlyArray<number>>>() const stickyIndexesOf = (model: Model, layout: Layout): ReadonlyArray<number> => { @@ -396,6 +418,7 @@ export const update = (model: Model, message: Message): ListReturn => { const isScroll = message._tag === "ScrolledList" || message._tag === "CompletedApplyScroll" || + message._tag === "SkippedApplyScroll" || message._tag === "CompletedWaitForScrollSettle" return withPool(result, isScroll ? "Scroll" : "Layout") } @@ -415,16 +438,13 @@ const updateScroll = (model: Model, message: Message): ListReturn => : startedScrolling(scrolled) }, CompletedWaitForScrollSettle: ({ version }) => - version === model.scrollEventVersion - ? { - model: modifyFields(model, { - isScrolling: () => false, - isSettleWaitPending: () => false, - renderedFromKey: () => null, - renderedToKey: () => null, - }), - } - : { model, commands: [WaitForScrollSettle({ version: model.scrollEventVersion })] }, + ScrollPhase.match(model.scrollPhase, { + Settled: () => ({ model }), + Scrolling: ({ eventVersion }) => + version === eventVersion + ? { model: modifyFields(model, { scrollPhase: () => ScrollPhase.Settled() }) } + : { model, commands: [WaitForScrollSettle({ version: eventVersion })] }, + }), ResizedViewport: ({ viewportHeight }) => reconcile(modifyFields(model, { viewportHeight: () => viewportHeight })), // A remounted element (the Files tab round-trip) starts at its own offset; the anchor is kept, @@ -451,6 +471,12 @@ const updateScroll = (model: Model, message: Message): ListReturn => anchor: () => anchorAt(model, layoutOf(model), scrollTop), }), }, + // Nothing moved and nothing was read: the scroll counts as applied, the anchor stays, and the + // next `MountedList` scrolls the new element back to it. + SkippedApplyScroll: ({ version }) => + version !== model.scrollVersion + ? { model } + : { model: modifyFields(model, { appliedScrollVersion: () => version }) }, }) /** The parent's rows changed (prepended page, new message, deletion). Keeps the anchor in place. */ @@ -509,7 +535,10 @@ const observeList = (element: Element): Stream.Stream<ObservedMessage> => if (!(element instanceof HTMLElement)) return () => undefined Queue.offerUnsafe( queue, - Message.MountedList({ scrollTop: element.scrollTop, viewportHeight: element.clientHeight }), + Message.MountedList({ + scrollTop: element.scrollTop, + viewportHeight: element.clientHeight, + }), ) const onScroll = () => @@ -600,13 +629,20 @@ export const isViewEqual = (a: Model, b: Model): boolean => a.measuredHeights === b.measuredHeights && a.estimatedRowHeightPx === b.estimatedRowHeightPx && a.viewportHeight === b.viewportHeight && - a.isScrolling === b.isScrolling && + isSamePhaseView(a.scrollPhase, b.scrollPhase) && a.pool === b.pool && - a.renderedFromKey === b.renderedFromKey && - a.renderedToKey === b.renderedToKey && a.activeStickyKey === b.activeStickyKey && a.stuckKey === b.stuckKey) +/** The phase as the view sees it: a new scroll event alone renders nothing new. */ +const isSamePhaseView = (a: ScrollPhase, b: ScrollPhase): boolean => + a === b || + (a._tag === "Settled" + ? b._tag === "Settled" + : b._tag === "Scrolling" && + a.renderedFromKey === b.renderedFromKey && + a.renderedToKey === b.renderedToKey) + export interface ViewInputs<Item, ParentMessage> { /** Same order as `model.keys`, oldest first. */ readonly items: ReadonlyArray<Item> @@ -628,18 +664,15 @@ export const view = <Item, ParentMessage>( const layout = layoutOf(model) const sticky = stickySetOf(model) const emptyContainer = (slot: number) => - h.keyed("div")( - `empty-${slot}`, - [ - h.Style({ - contain: "layout style paint", - left: "0px", - position: "absolute", - right: "0px", - top: `${OUT_OF_VIEW_PX}px`, - }), - ], - ) + h.keyed("div")(`empty-${slot}`, [ + h.Style({ + contain: "layout style paint", + left: "0px", + position: "absolute", + right: "0px", + top: `${OUT_OF_VIEW_PX}px`, + }), + ]) // Keyed by row, so switching between the overscan window and the containers moves nodes. const row = (index: number) => { const key = model.keys[index]! @@ -685,7 +718,12 @@ export const view = <Item, ParentMessage>( const index = key === null ? undefined : layout.indexByKey.get(key) return index === undefined ? emptyContainer(slot) : row(index) }) - const rows = model.viewportHeight === 0 ? [] : model.isScrolling ? overscanRows() : containerRows() + const rows = + model.viewportHeight === 0 + ? [] + : model.scrollPhase._tag === "Scrolling" + ? overscanRows() + : containerRows() return h.div( [ From 56f9493b3477125cbf6450e7d85f7ce90af91f05 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:25:37 +0200 Subject: [PATCH 32/52] refactor(web): unfurls as AsyncData with a timeout; keyed reaction pills; typed tooltip owners Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../web-foldkit/src/chat/embeds/url-embeds.ts | 92 +++++++++---- apps/web-foldkit/src/chat/message/row.ts | 32 ++++- apps/web-foldkit/src/chat/message/toolbar.ts | 3 +- apps/web-foldkit/src/chat/message/tooltips.ts | 11 +- apps/web-foldkit/src/chat/tooltip-host.ts | 18 ++- apps/web-foldkit/src/chat/unfurl.ts | 126 ++++++++++++++---- .../src/data/live-query-changes.ts | 25 ++-- 7 files changed, 236 insertions(+), 71 deletions(-) diff --git a/apps/web-foldkit/src/chat/embeds/url-embeds.ts b/apps/web-foldkit/src/chat/embeds/url-embeds.ts index 553881246..e1a57a38e 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 = <M>(h: HtmlBuilder<M>, 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 @@ -31,7 +32,13 @@ export const linkPreviewView = <M>(h: HtmlBuilder<M>, url: string, unfurl: Unfur 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 = <M>(h: HtmlBuilder<M>, 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, ], @@ -93,7 +103,11 @@ export const gifView = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>): Html => h.div( @@ -149,7 +162,12 @@ const tweetNotFound = <M>(h: HtmlBuilder<M>): Html => const truncate = (value: string, length: number) => value.length <= length ? value : `${value.slice(0, length - 3)}...` -const externalLink = <M>(h: HtmlBuilder<M>, href: string, className: string | null, children: ReadonlyArray<Html | string>) => +const externalLink = <M>( + h: HtmlBuilder<M>, + href: string, + className: string | null, + children: ReadonlyArray<Html | string>, +) => h.a( [ h.Href(href), @@ -162,7 +180,11 @@ const externalLink = <M>(h: HtmlBuilder<M>, href: string, className: string | nu const verifiedIcon = <M>(h: HtmlBuilder<M>): 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, tweet: TweetView): Html => ), ) -const tweetMedia = <M>(h: HtmlBuilder<M>, tweet: TweetView, toOpenPhoto: ((index: number) => M) | null): Html => { +const tweetMedia = <M>( + h: HtmlBuilder<M>, + 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 = <M>(h: HtmlBuilder<M>, 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 = <M>( 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", ), ], - [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/message/row.ts b/apps/web-foldkit/src/chat/message/row.ts index a46e4d820..e1bb552a9 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" @@ -85,19 +86,34 @@ export const avatarButton = <M>( ) /** One URL embed; tweet photos and GIFs open the image viewer. */ -const urlEmbedView = <M>(h: HtmlBuilder<M>, row: MessageRow, embed: UrlEmbed, context: RowContext<M>): Html => { +const urlEmbedView = <M>( + h: HtmlBuilder<M>, + row: MessageRow, + embed: UrlEmbed, + context: RowContext<M>, +): Html => { const messageId = row.message.id return UrlEmbed.match<Html>(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 +125,11 @@ const contentView = <M>(h: HtmlBuilder<M>, 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 d59691a5b..c3477d2f8 100644 --- a/apps/web-foldkit/src/chat/message/toolbar.ts +++ b/apps/web-foldkit/src/chat/message/toolbar.ts @@ -198,7 +198,7 @@ export const TrackMessageHover = Mount.defineStream("TrackMessageHover", { }) export interface ToolbarInputs<M> { - readonly messageId: string + readonly messageId: MessageId readonly isOwnMessage: boolean readonly tooltip: TooltipHost.Model readonly hoveredKey: string | null @@ -234,6 +234,7 @@ const action = <M>( ): Html => TooltipHost.tooltipTrigger(h, { key: `${inputs.messageId}:toolbar:${options.key}`, + messageId: inputs.messageId, active: inputs.tooltip, hoveredKey: inputs.hoveredKey, toMessage: inputs.toTooltipMessage, diff --git a/apps/web-foldkit/src/chat/message/tooltips.ts b/apps/web-foldkit/src/chat/message/tooltips.ts index 1369caa2e..c0a5f6851 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" @@ -63,21 +64,24 @@ const reactedBy = <M>( export const reactionButton = <M>( h: HtmlBuilder<M>, - messageId: string, + messageId: MessageId, reaction: AggregatedReaction, context: TooltipContext<M>, 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 +123,7 @@ export const reactionButton = <M>( /** `StatusEmojiWithTooltip` (interactive, no quiet hours). */ export const statusEmojiView = <M>( h: HtmlBuilder<M>, - messageId: string, + messageId: MessageId, status: StatusEmoji | null, context: TooltipContext<M>, ): Html => { @@ -129,6 +133,7 @@ export const statusEmojiView = <M>( ) return TooltipHost.tooltipTrigger(h, { key: `${messageId}:status`, + messageId, active: context.active, hoveredKey: context.hoveredKey, delayMs: 300, diff --git a/apps/web-foldkit/src/chat/tooltip-host.ts b/apps/web-foldkit/src/chat/tooltip-host.ts index 2bf270a41..06e8413e7 100644 --- a/apps/web-foldkit/src/chat/tooltip-host.ts +++ b/apps/web-foldkit/src/chat/tooltip-host.ts @@ -1,3 +1,4 @@ +import { MessageId } from "@hazel/schema" import { Schema } from "effect" import { Command, type Update } from "foldkit" import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" @@ -14,6 +15,8 @@ export type Model = typeof Model.Type export const Message = Schema.Struct({ key: Schema.String, + /** The message the trigger renders in (which row owns the open tooltip). */ + messageId: MessageId, delayMs: Schema.Number, message: Tooltip.Message, }) @@ -25,7 +28,7 @@ const isActivating = (message: Tooltip.Message) => export const update = <Wrapped>( model: Model, - { key, delayMs, message }: Message, + { key, messageId, delayMs, message }: Message, wrap: (message: Message) => Wrapped, ): Update.Return<Model, Wrapped> => { const current = @@ -39,7 +42,10 @@ export const update = <Wrapped>( 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, ), } @@ -55,6 +61,7 @@ const closedModel = (key: string, delayMs: number) => { export interface TriggerOptions<M> { 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 +88,16 @@ export const tooltipTrigger = <M>(h: HtmlBuilder<M>, options: TriggerOptions<M>) 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<Omit<Span, "type"> & { type: Span["type"] }>) => { +const addEntities = ( + result: Span[], + entities: ReadonlyArray<Omit<Span, "type"> & { 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<Omit<Span, "type"> 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<Omit<Span, "type"> const getEntities = (tweet: RawTweet): ReadonlyArray<TweetEntity> => { 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<TweetEntity> => { ) 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<TweetEntity> => { 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<Unfurls>(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 = <S extends Schema.Top & { readonly DecodingServices: never }>(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/data/live-query-changes.ts b/apps/web-foldkit/src/data/live-query-changes.ts index 77b15faea..422c0cf00 100644 --- a/apps/web-foldkit/src/data/live-query-changes.ts +++ b/apps/web-foldkit/src/data/live-query-changes.ts @@ -13,9 +13,9 @@ export interface LiveQueryWindow { readonly limit: number } -export interface ChangeSet<Row> { +export interface ChangeSet<Row, Id extends string = string> { /** Every row id of the result, in the query's order (deleted rows are simply absent). */ - readonly order: ReadonlyArray<string> + readonly order: ReadonlyArray<Id> /** Inserted or updated rows; the first emission carries all of them. */ readonly upserts: ReadonlyArray<Row> readonly isSnapshot: boolean @@ -26,13 +26,13 @@ export interface ChangeSet<Row> { * moves only the rows that enter or leave the window instead of recompiling the query and re-sending * every row. `readWindow` catches a page made before `windows` was subscribed. The query must be ordered. */ -export const liveQueryChangeSetStream = <Row, Message>( +export const liveQueryChangeSetStream = <Row, Message, Id extends string = string>( query: (q: InitialQueryBuilder, window: LiveQueryWindow) => QueryBuilder<any>, window: LiveQueryWindow, readWindow: () => LiveQueryWindow, windows: Stream.Stream<LiveQueryWindow>, - idOf: (row: Row) => string, - toMessage: (changes: ChangeSet<Row>) => Message, + idOf: (row: Row) => Id, + toMessage: (changes: ChangeSet<Row, Id>) => Message, ): Stream.Stream<Message> => Stream.callback<Message>((queue) => Effect.gen(function* () { @@ -43,7 +43,10 @@ export const liveQueryChangeSetStream = <Row, Message>( } const { collection } = yield* Effect.acquireRelease( Effect.sync(() => { - const collection = createLiveQueryCollection({ query: (q) => query(q, window), startSync: true }) + const collection = createLiveQueryCollection({ + query: (q) => query(q, window), + startSync: true, + }) let isReady = false // Joined results have composite keys, so the order is read off the rows' own ids. const order = () => (collection.toArray as ReadonlyArray<Row>).map(idOf) @@ -51,13 +54,19 @@ export const liveQueryChangeSetStream = <Row, Message>( isReady = true Queue.offerUnsafe( queue, - toMessage({ order: order(), upserts: collection.toArray as ReadonlyArray<Row>, isSnapshot: true }), + toMessage({ + order: order(), + upserts: collection.toArray as ReadonlyArray<Row>, + isSnapshot: true, + }), ) } const subscription = collection.subscribeChanges((changes) => { signal() if (!isReady) return snapshot() - const upserts = changes.flatMap((change) => (change.type === "delete" ? [] : [change.value as Row])) + const upserts = changes.flatMap((change) => + change.type === "delete" ? [] : [change.value as Row], + ) Queue.offerUnsafe(queue, toMessage({ order: order(), upserts, isSnapshot: false })) }) collection.onFirstReady(() => { From 4d7eb1cf06ca7f0c84207ce9a882ddf848133751 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:25:37 +0200 Subject: [PATCH 33/52] refactor(web): reshape the channel page and fix the chat P0s - Tab = Messages | Files, threadPanel owned by the page (draft, membership, creation, naming, messages and rows derived in update), one openOverlay union and a hover state machine in Overlays, structured overlay ownership. - Paging only while the Messages tab shows the list. - Thread reply images open the viewer; Files renders its image viewer. - Join #channel joins (JoinChannel with typed failure). - Thread create/name results only settle the panel for their own thread. - Typed reasons for clear-notifications, CopyText, CreateThread, GenerateThreadName. - View purity: row memo slots keyed by row, pure idle-context memo, hoisted mappers. - Dead code removed (UpdatedOrgSlug, UpdatedMessages, messagesStream); write.ts no longer imports page.ts; exhaustive message actions; navigation raised in update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/page/chat/banners.ts | 73 ++- .../page/chat/channel/actions.story.test.ts | 160 ++++- .../chat/channel/clear-notifications.test.ts | 34 +- .../page/chat/channel/clear-notifications.ts | 25 +- .../chat/channel/fixtures.test-support.ts | 24 +- .../src/page/chat/channel/index.ts | 47 +- .../page/chat/channel/messages.scene.test.ts | 136 +++- .../src/page/chat/channel/model.ts | 83 ++- .../src/page/chat/channel/page.test.ts | 107 ++- .../web-foldkit/src/page/chat/channel/page.ts | 369 ++++++----- .../src/page/chat/channel/read.story.test.ts | 361 ++++++++--- .../page/chat/channel/subscription.test.ts | 62 +- .../src/page/chat/channel/subscription.ts | 55 +- .../web-foldkit/src/page/chat/channel/view.ts | 323 +++++---- .../src/page/chat/channel/window.ts | 46 ++ .../src/page/chat/channel/write.ts | 421 ++++++++---- .../src/page/chat/composer-view.ts | 20 +- apps/web-foldkit/src/page/chat/data.ts | 30 +- apps/web-foldkit/src/page/chat/derive.ts | 84 ++- apps/web-foldkit/src/page/chat/files/data.ts | 7 +- .../src/page/chat/files/media-view.ts | 38 +- .../src/page/chat/files/page.test.ts | 30 +- apps/web-foldkit/src/page/chat/files/page.ts | 46 +- .../src/page/chat/files/subscriptions.ts | 4 +- apps/web-foldkit/src/page/chat/files/view.ts | 15 +- .../src/page/chat/message-actions.ts | 177 +++-- .../src/page/chat/overlay-views.ts | 64 +- apps/web-foldkit/src/page/chat/overlays.ts | 612 +++++++++++------- apps/web-foldkit/src/page/chat/row-context.ts | 216 ++++--- apps/web-foldkit/src/page/chat/rows.ts | 23 +- .../web-foldkit/src/page/chat/thread-panel.ts | 62 +- apps/web-foldkit/src/test/chat-fixtures.ts | 27 +- 32 files changed, 2484 insertions(+), 1297 deletions(-) create mode 100644 apps/web-foldkit/src/page/chat/channel/window.ts diff --git a/apps/web-foldkit/src/page/chat/banners.ts b/apps/web-foldkit/src/page/chat/banners.ts index 8721992ea..94743119d 100644 --- a/apps/web-foldkit/src/page/chat/banners.ts +++ b/apps/web-foldkit/src/page/chat/banners.ts @@ -6,7 +6,16 @@ import type { ChannelInfo } from "./queries" /** `ChannelJoinBanner` and `TypingIndicator`. */ -export const joinBannerView = <M>(h: HtmlBuilder<M>, channel: ChannelInfo | null): Html => +export interface JoinBannerInputs<M> { + readonly isJoining: boolean + readonly onJoin: M +} + +export const joinBannerView = <M>( + h: HtmlBuilder<M>, + channel: ChannelInfo | null, + inputs: JoinBannerInputs<M>, +): Html => channel === null ? h.empty : h.div( @@ -39,7 +48,16 @@ export const joinBannerView = <M>(h: HtmlBuilder<M>, channel: ChannelInfo | null ), ], ), - button(h, { intent: "primary", size: "lg" }, ["Join #", channel.name]), + button( + h, + { + intent: "primary", + size: "lg", + onPress: inputs.onJoin, + isPending: inputs.isJoining, + }, + ["Join #", channel.name], + ), ], ) @@ -67,34 +85,41 @@ export const typingUsersOf = ( const DOT = "inline-block size-1.5 animate-bounce rounded-full bg-muted-fg" -export const typingIndicatorView = <M>( - h: HtmlBuilder<M>, - typingUsers: ReadonlyArray<Pick<UserInfo, "firstName">>, -): Html => { - if (typingUsers.length === 0) return h.empty +/** The typing users' first names, reusing `previous` when they did not change (the composer's memo reads it). */ +export const typingNamesOf = (previous: ReadonlyArray<string>, typingUsers: ReadonlyArray<UserInfo>) => { + const next = typingUsers.map((user) => user.firstName) + return next.length === previous.length && next.every((name, index) => name === previous[index]) + ? previous + : next +} + +/** A polite live region that is always there, so screen readers announce who starts typing. */ +export const typingIndicatorView = <M>(h: HtmlBuilder<M>, firstNames: ReadonlyArray<string>): Html => { const text = - typingUsers.length === 1 - ? `${typingUsers[0]!.firstName} is typing...` - : typingUsers.length === 2 - ? `${typingUsers[0]!.firstName} and ${typingUsers[1]!.firstName} are typing...` - : `${typingUsers[0]!.firstName} and ${typingUsers.length - 1} others are typing...` + firstNames.length === 1 + ? `${firstNames[0]} is typing...` + : firstNames.length === 2 + ? `${firstNames[0]} and ${firstNames[1]} are typing...` + : `${firstNames[0]} and ${firstNames.length - 1} others are typing...` return h.div( - [h.Class("absolute bottom-full left-4 pb-1")], - [ - h.div( - [h.Class("flex h-3 items-center gap-2 text-muted-fg text-xs")], - [ + [h.Class("absolute bottom-full left-4 pb-1"), h.AriaLive("polite")], + firstNames.length === 0 + ? [] + : [ h.div( - [h.Class("flex gap-1")], + [h.Class("flex h-3 items-center gap-2 text-muted-fg text-xs")], [ - h.span([h.Class(`${DOT} [animation-delay:-0.3s]`)]), - h.span([h.Class(`${DOT} [animation-delay:-0.15s]`)]), - h.span([h.Class(DOT)]), + h.div( + [h.Class("flex gap-1")], + [ + h.span([h.Class(`${DOT} [animation-delay:-0.3s]`)]), + h.span([h.Class(`${DOT} [animation-delay:-0.15s]`)]), + h.span([h.Class(DOT)]), + ], + ), + h.span([], [text]), ], ), - h.span([], [text]), ], - ), - ], ) } diff --git a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts index c1d29631c..bb12fc5c4 100644 --- a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts @@ -12,6 +12,7 @@ import { CreateThread, GenerateThreadChannelId, GenerateThreadName, + JoinChannel, PinMessage, ToggleReaction, UnpinMessage, @@ -33,7 +34,9 @@ import * as Write from "./write" 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 overlays = (inner: Overlays.Message) => Message.GotOverlaysMessage({ message: inner }) @@ -46,7 +49,9 @@ describe("reactions", () => { updateWithShared, given(loadedModel()), message(overlays(Overlays.Message.ClickedReaction({ messageId: graceMessageId, emoji: "👍" }))), - Command.expectExact(ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada })), + Command.expectExact( + ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), + ), Command.resolve(ToggleReaction, ActionMessage.CompletedToggleReaction({ toast: null })), ) }) @@ -58,17 +63,20 @@ describe("message actions", () => { updateWithShared, given(loadedModel()), message(overlays(Overlays.Message.ClickedDelete({ messageId: adaMessageId }))), - expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "DeleteMessage", messageId: adaMessageId } })), + expectOutMessage( + PageOutMessage.RequestedModal({ modal: { _tag: "DeleteMessage", messageId: adaMessageId } }), + ), Command.expectNone(), ) }) - test("Copy writes the content and toasts", () => { + test("Copy writes the content, then toasts once the clipboard took it", () => { story( updateWithShared, given(loadedModel()), message(overlays(Overlays.Message.ClickedCopy({ messageId: graceMessageId }))), - Command.expectExact(CopyText({ text: "Launch window confirmed" })), + Command.expectExact(CopyText({ text: "Launch window confirmed", content: "MessageText" })), + Command.resolve(CopyText, ActionMessage.SucceededCopyText({ content: "MessageText" })), expectOutMessage( PageOutMessage.RequestedToast({ toast: { @@ -78,10 +86,35 @@ describe("message actions", () => { }, }), ), - Command.resolve(CopyText, ActionMessage.CompletedCopyText()), ) }) + test("a refused clipboard reports the failure instead of a success", () => { + story( + updateWithShared, + given(loadedModel()), + message(overlays(Overlays.Message.ClickedCopy({ messageId: graceMessageId }))), + Command.resolve(CopyText, ActionMessage.FailedCopyText({ reason: "ClipboardDenied" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Couldn't copy", + description: "Your browser blocked access to the clipboard.", + }, + }), + ), + ) + }) + + test("the context menu's Add Reaction opens the picker modal through the Modal API", () => { + const opened = Write.handleOverlaysOut(loadedModel(), out("add-reaction")) + expect(Overlays.reactionModalOf(opened.model.overlays)).toMatchObject({ + messageId: graceMessageId, + modal: { id: "reaction-picker-modal", isOpen: true }, + }) + }) + test("Pin sends `pinnedMessage.create`; a pinned message unpins by its pin id", () => { const pinnedId = Schema.decodeSync(PinnedMessageId)("00000000-0000-4000-8000-000000005555") const pinned: Model = { @@ -117,13 +150,21 @@ describe("message actions", () => { story( updateWithShared, given(start.model), - message(Message.GotActionMessage({ - message: ActionMessage.CompletedGenerateThreadChannelId({ messageId: graceMessageId, threadChannelId }), - })), + message( + Message.GotActionMessage({ + message: ActionMessage.CompletedGenerateThreadChannelId({ + messageId: graceMessageId, + threadChannelId, + }), + }), + ), model((current) => { - expect(current.overlays.thread).toEqual({ threadChannelId, messageId: graceMessageId }) - expect(current.pendingThreadChannelId).toBe(threadChannelId) - expect(current.threadDraft?.composer.placeholder).toBe("Reply in thread...") + expect(current.threadPanel).toMatchObject({ + threadChannelId, + messageId: graceMessageId, + creation: "Creating", + }) + expect(current.threadPanel?.draft.composer.placeholder).toBe("Reply in thread...") }), Command.expectExact( CreateThread({ @@ -135,34 +176,115 @@ describe("message actions", () => { }), ), Command.resolve(CreateThread, ActionMessage.SucceededCreateThread({ threadChannelId })), - model((current) => expect(current.pendingThreadChannelId).toBeNull()), + model((current) => expect(current.threadPanel?.creation).toBe("Created")), message(Message.ClickedGenerateThreadName()), Command.expectExact(GenerateThreadName({ channelId: threadChannelId })), - Command.resolve(GenerateThreadName, ActionMessage.CompletedGenerateThreadName({ threadChannelId, toast: null })), + model((current) => expect(current.threadPanel?.isNaming).toBe(true)), + Command.resolve( + GenerateThreadName, + ActionMessage.SucceededGenerateThreadName({ threadChannelId }), + ), + model((current) => expect(current.threadPanel?.isNaming).toBe(false)), message(Message.ClickedRenameThread()), - expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "RenameThread", threadId: threadChannelId } })), + expectOutMessage( + PageOutMessage.RequestedModal({ modal: { _tag: "RenameThread", threadId: threadChannelId } }), + ), ) expect(start.commands?.map((command) => command.name)).toEqual([GenerateThreadChannelId.name]) }) }) +describe("join banner", () => { + const notMember = (): Model => ({ + ...loadedModel(), + members: loadedModel().members?.filter((member) => member.userId !== ada) ?? null, + }) + + test("Join #channel runs `joinChannelAction` once; success toasts and ends the pending state", () => { + story( + updateWithShared, + given(notMember()), + message(Message.ClickedJoinChannel()), + Command.expectExact(JoinChannel({ channelId, userId: ada })), + model((current) => { + expect(current.isJoining).toBe(true) + // A second press while it runs sends nothing. + expect(updateWithShared(current, Message.ClickedJoinChannel()).commands ?? []).toEqual([]) + }), + Command.resolve(JoinChannel, ActionMessage.SucceededJoinChannel()), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { intent: "success", title: "Successfully joined channel", description: null }, + }), + ), + model((current) => expect(current.isJoining).toBe(false)), + ) + }) + + test("a failed join shows the reason's copy and lets the reader try again", () => { + story( + updateWithShared, + given(notMember()), + message(Message.ClickedJoinChannel()), + Command.resolve(JoinChannel, ActionMessage.FailedJoinChannel({ reason: "NotFound" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Channel not found", + description: "This channel may have been deleted.", + }, + }), + ), + model((current) => expect(current.isJoining).toBe(false)), + ) + }) +}) + +describe("navigation", () => { + test("a tab click and the mobile menu are raised from update", () => { + story( + updateWithShared, + given(loadedModel()), + message(Message.ClickedTab({ tab: "files" })), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + href: `/hazel/chat/${channelId}/files`, + replace: false, + }), + ), + message(Message.ClickedMobileMenu()), + expectOutMessage(PageOutMessage.RequestedMobileSidebar()), + ) + }) +}) + describe("composer pickers", () => { test("an emoji from the picker goes into the editor; a custom one as an inline emoji", () => { const editorId = `composer-${channelId}` story( updateWithShared, given(loadedModel()), - message(Message.GotDraftMessage({ message: Draft.Message.SelectedEmoji({ emoji: "🎉", label: "party", imageUrl: null }) })), + message( + Message.GotDraftMessage({ + message: Draft.Message.SelectedEmoji({ emoji: "🎉", label: "party", imageUrl: null }), + }), + ), Command.expectExact(EditorCommands.InsertEditorText({ editorId, text: "🎉" })), Command.resolve(EditorCommands.InsertEditorText, Composer.Message.CompletedInsertEditorText()), message( Message.GotDraftMessage({ - message: Draft.Message.SelectedEmoji({ emoji: "custom:shipit", label: "shipit", imageUrl: "/r2/shipit.png" }), + message: Draft.Message.SelectedEmoji({ + emoji: "custom:shipit", + label: "shipit", + imageUrl: "/r2/shipit.png", + }), }), ), - Command.expectExact(EditorCommands.InsertCustomEmoji({ editorId, name: "shipit", imageUrl: "/r2/shipit.png" })), + Command.expectExact( + EditorCommands.InsertCustomEmoji({ editorId, name: "shipit", imageUrl: "/r2/shipit.png" }), + ), Command.resolve(EditorCommands.InsertCustomEmoji, Composer.Message.CompletedInsertCustomEmoji()), ) }) }) - diff --git a/apps/web-foldkit/src/page/chat/channel/clear-notifications.test.ts b/apps/web-foldkit/src/page/chat/channel/clear-notifications.test.ts index c678894b4..957d9a4ee 100644 --- a/apps/web-foldkit/src/page/chat/channel/clear-notifications.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/clear-notifications.test.ts @@ -1,13 +1,16 @@ import { describe, expect, test, vi } from "vitest" import { sharedDefaults } from "../../test-shared" import { ClearChannelNotifications, clearNotificationsOnMount } from "./clear-notifications" -import { channelId, loadedModel, shared } from "./fixtures.test-support" +import { channelId, loadedModel, shared, updateWithShared } from "./fixtures.test-support" +import { Message } from "./model" /** `ChatRouteContent`'s mount effect: once per visit, inside the loaded org layout. */ 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 commandNames = (result: ReturnType<typeof clearNotificationsOnMount>) => @@ -21,8 +24,29 @@ describe("channelMember.clearNotifications on channel entry", () => { }) test("waits while auth, the user or the organization is still loading", () => { - expect(clearNotificationsOnMount(loadedModel(), { ...shared, auth: "Loading" }).commands ?? []).toEqual([]) - expect(clearNotificationsOnMount(loadedModel(), { ...shared, currentUser: null }).commands ?? []).toEqual([]) - expect(clearNotificationsOnMount(loadedModel(), { ...sharedDefaults, ...shared, organization: null }).commands ?? []).toEqual([]) + expect( + clearNotificationsOnMount(loadedModel(), { ...shared, auth: "Loading" }).commands ?? [], + ).toEqual([]) + expect( + clearNotificationsOnMount(loadedModel(), { ...shared, currentUser: null }).commands ?? [], + ).toEqual([]) + expect( + clearNotificationsOnMount(loadedModel(), { ...sharedDefaults, ...shared, organization: null }) + .commands ?? [], + ).toEqual([]) + }) + + test("only a success marks the visit cleared; a failure is sent again on the next shared change", () => { + const sent = clearNotificationsOnMount(loadedModel(), shared).model + const failed = updateWithShared( + sent, + Message.FailedClearNotifications({ channelId, reason: "Offline" }), + ).model + expect(commandNames(clearNotificationsOnMount(failed, shared))).toEqual([ + [ClearChannelNotifications.name, { channelId }], + ]) + const cleared = updateWithShared(sent, Message.SucceededClearNotifications({ channelId })).model + expect(cleared.notificationsClear).toBe("Cleared") + expect(clearNotificationsOnMount(cleared, shared).commands ?? []).toEqual([]) }) }) diff --git a/apps/web-foldkit/src/page/chat/channel/clear-notifications.ts b/apps/web-foldkit/src/page/chat/channel/clear-notifications.ts index 5963035ed..bc488cdca 100644 --- a/apps/web-foldkit/src/page/chat/channel/clear-notifications.ts +++ b/apps/web-foldkit/src/page/chat/channel/clear-notifications.ts @@ -1,7 +1,8 @@ import { ChannelId } from "@hazel/schema" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" +import { catchCommon } from "../../../data/actions" import { HazelRpc } from "../../../rpc" import type { Shared } from "../../contract" import { Message, type Model, type PageReturn } from "./model" @@ -12,14 +13,15 @@ export const ClearChannelNotifications = Command.define("ClearChannelNotificatio args: { channelId: ChannelId }, messages: [Message.SucceededClearNotifications, Message.FailedClearNotifications], execute: ({ channelId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - yield* client("channelMember.clearNotifications", { channelId }) - return Message.SucceededClearNotifications() - }).pipe( - Effect.catch((error) => - Effect.succeed(Message.FailedClearNotifications({ reason: String(error) })), - ), + HazelRpc.use((rpc) => rpc("channelMember.clearNotifications", { channelId })).pipe( + Effect.as(Message.SucceededClearNotifications({ channelId })), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed( + Message.FailedClearNotifications({ channelId, reason: "ChannelNotFound" }), + ), + }), + catchCommon((reason) => Message.FailedClearNotifications({ channelId, reason })), ), }) @@ -27,10 +29,11 @@ export const ClearChannelNotifications = Command.define("ClearChannelNotificatio const isMounted = (shared: Shared) => shared.auth === "SignedIn" && shared.currentUser !== null && shared.organization !== null +/** Sends the clear unless it is in flight or done; a failed clear is sent again on the next shared change. */ export const clearNotificationsOnMount = (model: Model, shared: Shared): PageReturn => - model.hasClearedNotifications || !isMounted(shared) + model.notificationsClear !== "NotSent" || !isMounted(shared) ? { model } : { - model: modifyFields(model, { hasClearedNotifications: () => true }), + model: modifyFields(model, { notificationsClear: () => "Sending" }), commands: [ClearChannelNotifications({ channelId: model.channelId })], } diff --git a/apps/web-foldkit/src/page/chat/channel/fixtures.test-support.ts b/apps/web-foldkit/src/page/chat/channel/fixtures.test-support.ts index f78345ab8..22a79199f 100644 --- a/apps/web-foldkit/src/page/chat/channel/fixtures.test-support.ts +++ b/apps/web-foldkit/src/page/chat/channel/fixtures.test-support.ts @@ -4,7 +4,7 @@ import type { Shared } from "../../contract" import { sharedDefaults } from "../../test-shared" import type { ChatMessage } from "../rows" import { init, type Model, update } from "./page" -import type { Message } from "./model" +import { Message } from "./model" /** Fixtures for the channel page's write-path stories: Ada in #general with two messages. */ @@ -59,18 +59,34 @@ export const messages: ReadonlyArray<ChatMessage> = [ /** The page with the channel, its members and messages loaded. */ export const loadedModel = (): Model => { - const base = init(channelId, ada) + const base = init(channelId, "hazel", ada) return { ...base, - channel: { id: channelId, name: "general", type: "public", icon: null, organizationId, parentChannelId: null }, + channel: { + id: channelId, + name: "general", + type: "public", + icon: null, + organizationId, + parentChannelId: null, + }, members: [ { id: adaMemberId, userId: ada, isHidden: false, createdAtMs: 0 }, - { id: Schema.decodeSync(ChannelMemberId)(uuid(6)), userId: grace, isHidden: false, createdAtMs: 0 }, + { + id: Schema.decodeSync(ChannelMemberId)(uuid(6)), + userId: grace, + isHidden: false, + createdAtMs: 0, + }, ], messages, hasLoadedMessages: true, } } +/** The messages query's first result: every message upserted, newest first. */ +export const loadedMessages = (loaded: ReadonlyArray<ChatMessage>) => + Message.ChangedMessages({ order: loaded.map((message) => message.id), upserts: loaded }) + /** `update` with the signed-in session, as `index.ts` calls it. */ export const updateWithShared = (model: Model, message: Message) => update(model, message, shared) diff --git a/apps/web-foldkit/src/page/chat/channel/index.ts b/apps/web-foldkit/src/page/chat/channel/index.ts index 6c3e9f76e..e6d90ad38 100644 --- a/apps/web-foldkit/src/page/chat/channel/index.ts +++ b/apps/web-foldkit/src/page/chat/channel/index.ts @@ -1,26 +1,14 @@ import { Submodel } from "foldkit" import type { RouteOf } from "../../../route" import { definePage, type PageViewInputs } from "../../contract" -import { PageOutMessage } from "../../out-message" -import { - type ChatTab, - init, - Message, - Model, - setCurrentUserId, - setTab, - switchChannel, - tabPath, - update, -} from "./page" +import { type ChatTab, init, Message, Model, setCurrentUserId, setTab, switchChannel, update } from "./page" import { clearNotificationsOnMount } from "./clear-notifications" import { subscriptions } from "./subscription" import { view as channelView } from "./view" +import { andThen } from "./write" /** `/$orgSlug/chat/$id` with its Messages, Files and All Media tabs (one layout, one page). */ -const toSelf = (message: Message) => message - type ChannelRoute = RouteOf<"ChatChannel" | "ChatFiles" | "ChatFilesMedia"> const tabOf = (route: ChannelRoute): ChatTab => @@ -36,39 +24,24 @@ export const page = definePage( key: (route) => route.orgSlug, init: (route, shared) => clearNotificationsOnMount( - init(route.channelId, shared.currentUser?.id ?? null, { - tab: tabOf(route), - orgSlug: route.orgSlug, - }), + init(route.channelId, route.orgSlug, shared.currentUser?.id ?? null, { tab: tabOf(route) }), shared, ), - update: (model, message, shared) => - message._tag === "ClickedMobileMenu" - ? { model, outMessage: PageOutMessage.RequestedMobileSidebar() } - : message._tag === "ClickedTab" && message.tab !== model.tab && model.orgSlug !== null - ? { - model, - outMessage: PageOutMessage.RequestedNavigation({ - href: tabPath(model.orgSlug, model.channelId, message.tab), - replace: false, - }), - } - : update(model, message, shared), + update, routeChanged: (model, route, shared) => route.channelId === model.channelId ? { model: setTab(model, tabOf(route)) } : clearNotificationsOnMount( - switchChannel(model, route.channelId, { tab: tabOf(route), orgSlug: route.orgSlug }), + switchChannel(model, route.channelId, { tab: tabOf(route) }), shared, ), view: Submodel.defineView<Model, Message, PageViewInputs>((model, inputs, h) => - channelView(h, model, toSelf, inputs.shared.isMobile, inputs.shared.nowMs), + channelView(h, model, { isMobile: inputs.shared.isMobile, nowMs: inputs.shared.nowMs }), ), subscriptions, - sharedChanged: (model, shared) => { - const withUser = setCurrentUserId(model, shared.currentUser?.id ?? null) - const cleared = clearNotificationsOnMount(withUser.model, shared) - return { ...cleared, commands: [...(withUser.commands ?? []), ...(cleared.commands ?? [])] } - }, + sharedChanged: (model, shared) => + andThen(setCurrentUserId(model, shared.currentUser?.id ?? null), (next) => + clearNotificationsOnMount(next, shared), + ), }, ) diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 753617b72..ef58c9c5d 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -5,7 +5,14 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import * as Composer from "../../../composer/composer" import * as EditorCommands from "../../../composer/editor-commands" -import { channelScene, derivedModel, mountedChannel, mountedToolbar, overlays, draft } from "../../../test/chat-fixtures" +import { + channelScene, + derivedModel, + mountedChannel, + mountedToolbar, + overlays, + draft, +} from "../../../test/chat-fixtures" import * as Menu from "../../../ui/menu" import * as Draft from "../../../composer/draft" import { DropEvent, TrackFileDrop } from "../../../composer/drop" @@ -18,8 +25,22 @@ import type { EmojiData } from "../../../emoji-picker/data" import * as Overlays from "../overlays" import { imageAttachmentOf } from "../../../test/chat-messages" import { PageOutMessage } from "../../out-message" -import { ActionMessage, CreateThread, DownloadImage, GenerateThreadChannelId, ToggleReaction } from "../message-actions" -import { ada, adaMessageId, channelId, graceMessageId, updateWithShared } from "./fixtures.test-support" +import { + ActionMessage, + CreateThread, + DownloadImage, + GenerateThreadChannelId, + JoinChannel, + ToggleReaction, +} from "../message-actions" +import { + ada, + adaMessageId, + channelId, + graceMessageId, + loadedModel, + updateWithShared, +} from "./fixtures.test-support" /** The channel page through its view: reading, the hover toolbar's actions and the composer indicators. */ @@ -38,6 +59,22 @@ describe("reading", () => { }) }) +describe("join banner", () => { + test("a non-member's Join #channel joins the channel", () => { + const base = loadedModel() + Scene.scene( + channelScene, + Scene.given({ + ...base, + members: base.members?.filter((member) => member.userId !== ada) ?? null, + }), + Scene.click(Scene.role("button", { name: "Join #general" })), + Scene.Command.expectExact(JoinChannel({ channelId, userId: ada })), + Scene.Command.resolve(JoinChannel, ActionMessage.SucceededJoinChannel()), + ) + }) +}) + describe("hover toolbar", () => { test("react: the quick reaction toggles it", () => { Scene.scene( @@ -89,7 +126,10 @@ describe("hover toolbar", () => { Scene.Command.expectExact( EditorCommands.SetEditorContent({ editorId, markdown: "Shipped the onboarding copy" }), ), - Scene.Command.resolve(EditorCommands.SetEditorContent, Composer.Message.CompletedSetEditorContent()), + Scene.Command.resolve( + EditorCommands.SetEditorContent, + Composer.Message.CompletedSetEditorContent(), + ), Scene.click(Scene.role("button", { name: "Cancel editing" })), Scene.Command.expectExact(EditorCommands.ClearEditor({ editorId })), Scene.Command.resolve(EditorCommands.ClearEditor, Composer.Message.CompletedClearEditor()), @@ -112,7 +152,6 @@ describe("hover toolbar", () => { }) }) - describe("threads", () => { const threadEditorId = `thread-composer-${threadChannelId}` const threadDraft = (message: Draft.Message) => Message.GotThreadDraftMessage({ message }) @@ -130,7 +169,10 @@ describe("threads", () => { Scene.Command.expectExact(GenerateThreadChannelId({ messageId: graceMessageId })), Scene.Command.resolve( GenerateThreadChannelId, - ActionMessage.CompletedGenerateThreadChannelId({ messageId: graceMessageId, threadChannelId }), + ActionMessage.CompletedGenerateThreadChannelId({ + messageId: graceMessageId, + threadChannelId, + }), ), Scene.expect(Scene.role("heading", { name: "Thread" })).toExist(), Scene.expect(Scene.text("Creating thread...")).toExist(), @@ -139,16 +181,30 @@ describe("threads", () => { Scene.Mount.resolveAll( [ MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread..." }), - threadDraft(Draft.Message.GotComposerMessage({ message: Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }) })), + threadDraft( + Draft.Message.GotComposerMessage({ + message: Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }), + }), + ), ], [ { name: "TrackFileDrop" }, - threadDraft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ isDraggingOnPage: false, isDropTarget: false }) })), + threadDraft( + Draft.Message.ReceivedDropEvent({ + event: DropEvent.ChangedDragState({ + isDraggingOnPage: false, + isDropTarget: false, + }), + }), + ), ], ), Scene.click(Scene.role("button", { name: "Close thread" })), Scene.expect(Scene.role("heading", { name: "Thread" })).toBeAbsent(), - Scene.Mount.expectEnded(MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread..." }), TrackFileDrop), + Scene.Mount.expectEnded( + MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread..." }), + TrackFileDrop, + ), ) }) }) @@ -190,11 +246,19 @@ describe("image viewer", () => { Scene.click(Scene.altText("photo-1.png")), viewerMounted(0), // Legacy nests the action Button inside its bare `TooltipTrigger` Button; the inner one acts. - Scene.click(Scene.within(Scene.role("button", { name: "download" }), Scene.selector("button button"))), - Scene.Command.expectExact(DownloadImage({ url: "https://cdn.hazel.sh/photo-1.png", fileName: "photo-1.png" })), + Scene.click( + Scene.within(Scene.role("button", { name: "download" }), Scene.selector("button button")), + ), + Scene.Command.expectExact( + DownloadImage({ url: "https://cdn.hazel.sh/photo-1.png", fileName: "photo-1.png" }), + ), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "success", title: "Image downloaded", description: "Your image has been downloaded." }, + toast: { + intent: "success", + title: "Image downloaded", + description: "Your image has been downloaded.", + }, }), ), Scene.Command.resolve(DownloadImage, ActionMessage.CompletedDownloadImage()), @@ -210,11 +274,23 @@ describe("composer emoji picker", () => { { emoji: "👋", category: 0, label: "waving hand", version: 1, tags: ["wave"] }, ], categories: [{ index: 0, label: "Smileys & emotion" }], - skinTones: { light: "Light", "medium-light": "Medium-light", medium: "Medium", "medium-dark": "Medium-dark", dark: "Dark" }, + skinTones: { + light: "Light", + "medium-light": "Medium-light", + medium: "Medium", + "medium-dark": "Medium-dark", + dark: "Dark", + }, } - const measured = Picker.Message.MeasuredPicker({ rowHeight: 40, categoryHeaderHeight: 40, viewportWidth: 376, viewportHeight: 222 }) - const emojiDialog = (message: EmojiDialog.Message) => draft(Draft.Message.GotEmojiPickerMessage({ message })) + const measured = Picker.Message.MeasuredPicker({ + rowHeight: 40, + categoryHeaderHeight: 40, + viewportWidth: 376, + viewportHeight: 222, + }) + const emojiDialog = (message: EmojiDialog.Message) => + draft(Draft.Message.GotEmojiPickerMessage({ message })) test("Emoji opens the picker dialog with its data; Escape closes it", () => { Scene.scene( @@ -226,18 +302,38 @@ describe("composer emoji picker", () => { Scene.Command.expectExact(Picker.LoadEmojiData()), Scene.Command.resolve(Picker.LoadEmojiData, Picker.Message.SucceededLoadEmojiData({ data })), Scene.Mount.resolveAll( - [{ name: "PortalPickerPopover" }, emojiDialog(EmojiDialog.Message.ReceivedPopoverEvent({ event: PopoverEvent.CompletedPortalPickerPopover() }))], - [{ name: "MeasurePicker" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: measured }))], - [{ name: "TrackPickerKeys" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: Picker.Message.PressedNavigationKey({ key: "ArrowRight" }) }))], + [ + { name: "PortalPickerPopover" }, + emojiDialog( + EmojiDialog.Message.ReceivedPopoverEvent({ + event: PopoverEvent.CompletedPortalPickerPopover(), + }), + ), + ], + [ + { name: "MeasurePicker" }, + emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: measured })), + ], + [ + { name: "TrackPickerKeys" }, + emojiDialog( + EmojiDialog.Message.GotPickerMessage({ + message: Picker.Message.PressedNavigationKey({ key: "ArrowRight" }), + }), + ), + ], ), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toExist(), Scene.expect(Scene.role("gridcell", { name: "party popper" })).toExist(), // The popover around the dialog owns the Escape key (React Aria's Popover). Scene.keydown(Scene.selector(`#${popoverId(`${editorId}-emoji-picker`)}`), "Escape"), - Scene.Mount.expectEnded({ name: "PortalPickerPopover" }, { name: "MeasurePicker" }, { name: "TrackPickerKeys" }), + Scene.Mount.expectEnded( + { name: "PortalPickerPopover" }, + { name: "MeasurePicker" }, + { name: "TrackPickerKeys" }, + ), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toBeAbsent(), Scene.Command.expectNone(), ) }) - }) diff --git a/apps/web-foldkit/src/page/chat/channel/model.ts b/apps/web-foldkit/src/page/chat/channel/model.ts index 94d31e0b0..9f6985720 100644 --- a/apps/web-foldkit/src/page/chat/channel/model.ts +++ b/apps/web-foldkit/src/page/chat/channel/model.ts @@ -2,6 +2,8 @@ import { ChannelId, ChannelMemberId, MessageId, UserId } from "@hazel/schema" import { Schema } from "effect" import type { Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as Live from "../../../chat/live-state" import * as Unfurl from "../../../chat/unfurl" import * as Draft from "../../../composer/draft" @@ -30,15 +32,46 @@ import { ChatMessage, ChatReaction, DisplayRow } from "../rows" /** The channel page's Model and Message (`page.ts` holds init and update, `write.ts` the write path). */ +/** The tab a route names (`$id`, `$id/files`, `$id/files/media`); the tab bar and links speak it. */ export const ChatTab = Schema.Literals(["messages", "files", "media"]) export type ChatTab = typeof ChatTab.Type // MODEL +/** The tab shown: the message list, or the Files Submodel (which knows whether it shows files or media). */ +export const Tab = defineTaggedUnion({ + Messages: {}, + Files: { files: FilesPage.Model }, +}) +export type Tab = typeof Tab.Type + +/** `isThreadCreating`: the panel opens on the optimistic id while `channel.createThread` runs. */ +export const ThreadCreation = Schema.Literals(["Creating", "Created"]) +export type ThreadCreation = typeof ThreadCreation.Type + +/** `useChatThread`: the thread panel beside the channel, with everything that lives and dies with it. */ +export const ThreadPanel = Schema.Struct({ + threadChannelId: ChannelId, + /** The message the thread hangs off. */ + messageId: MessageId, + /** The panel's own composer draft. */ + draft: Draft.Model, + /** The signed-in user's membership of the thread (its typing indicator). */ + memberId: Schema.NullOr(ChannelMemberId), + creation: ThreadCreation, + /** "Generate thread name" is running for this thread. */ + isNaming: Schema.Boolean, + /** The thread's messages, oldest first. */ + messages: Schema.Array(ChatMessage), + /** Their rows, derived in `update` like the channel's. */ + rows: Schema.Array(DisplayRow.MessageRow), +}) +export type ThreadPanel = typeof ThreadPanel.Type + export const Model = Schema.Struct({ channelId: ChannelId, - tab: ChatTab, - orgSlug: Schema.NullOr(Schema.String), + tab: Tab, + orgSlug: Schema.String, currentUserId: Schema.NullOr(UserId), channel: Schema.NullOr(ChannelInfo), parentChannel: Schema.NullOr(ParentChannelInfo), @@ -50,10 +83,10 @@ export const Model = Schema.Struct({ members: Schema.NullOr(Schema.Array(ChannelMemberInfo)), /** `PinnedMessagesModal`'s pins, oldest pin first. */ pinned: Schema.Array(PinnedInfo), - /** The open thread panel's messages, oldest first. */ - threadMessages: Schema.Array(ChatMessage), typing: Schema.Array(TypingInfo), typingNowMs: Schema.Number, + /** First names of the other members typing now (`useTypingIndicators`), derived in `update`. */ + typingNames: Schema.Array(Schema.String), /** Ids the reply and thread lookups select, kept stable while the window does not change them. */ replyIds: Schema.Array(MessageId), threadIds: Schema.Array(ChannelId), @@ -70,19 +103,13 @@ export const Model = Schema.Struct({ offset: Schema.Number, list: MessageList.Model, overlays: Overlays.Model, - /** The Files tab (`$id/files` and `$id/files/media`), present while one of them is shown. */ - files: Schema.NullOr(FilesPage.Model), /** The channel composer's draft (`ChatProvider` + `ComposerEditor` state). */ draft: Draft.Model, - /** The thread panel's own draft, while a thread is open. */ - threadDraft: Schema.NullOr(Draft.Model), - /** The signed-in user's membership of the open thread (its typing indicator). */ - threadMemberId: Schema.NullOr(ChannelMemberId), - /** `isThreadCreating`: the thread the panel shows is still being created. */ - pendingThreadChannelId: Schema.NullOr(ChannelId), - isGeneratingThreadName: Schema.Boolean, - /** `channelMember.clearNotifications` was sent for this visit. */ - hasClearedNotifications: Schema.Boolean, + threadPanel: Schema.NullOr(ThreadPanel), + /** `channelMember.clearNotifications` for this visit: not sent yet (or failed), in flight, done. */ + notificationsClear: Schema.Literals(["NotSent", "Sending", "Cleared"]), + /** The non-member's "Join #channel" is running. */ + isJoining: Schema.Boolean, /** * Row heights measured in channels visited earlier, by channel, least recent first. A revisit * lays its rows out at their real heights on the switch frame; rows re-measure as they render. @@ -91,14 +118,16 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type +/** What can go wrong clearing the channel's notifications, beyond the shared reasons. */ +export const ClearNotificationsFailure = Schema.Union([Schema.Literals(["ChannelNotFound"]), CommonReason]) +export type ClearNotificationsFailure = typeof ClearNotificationsFailure.Type + // MESSAGE export const Message = defineMessageUnion({ UpdatedChannel: { channel: Schema.NullOr(ChannelInfo) }, - UpdatedOrgSlug: { orgSlug: Schema.NullOr(Schema.String) }, ClickedTab: { tab: ChatTab }, UpdatedParentChannel: { channel: Schema.NullOr(ParentChannelInfo) }, - UpdatedMessages: { messages: Schema.Array(ChatMessage) }, /** The window as a change set: the ids in order, plus inserted and updated rows. */ ChangedMessages: { order: Schema.Array(MessageId), upserts: Schema.Array(ChatMessage) }, UpdatedReactions: { reactions: Schema.Array(ChatReaction) }, @@ -113,7 +142,8 @@ export const Message = defineMessageUnion({ UpdatedReplyTargets: { targets: Schema.Array(ReplyTarget) }, UpdatedMembers: { members: Schema.Array(ChannelMemberInfo) }, UpdatedPinned: { pins: Schema.Array(PinnedInfo) }, - UpdatedThreadPanelMessages: { messages: Schema.Array(ChatMessage) }, + /** The open thread's messages, oldest first; tagged with the thread they were queried for. */ + UpdatedThreadPanelMessages: { threadChannelId: ChannelId, messages: Schema.Array(ChatMessage) }, UpdatedTyping: { typing: Schema.Array(TypingInfo) }, TickedTypingClock: { nowMs: Schema.Number }, GotListMessage: { message: MessageList.Message }, @@ -125,17 +155,24 @@ export const Message = defineMessageUnion({ GotDraftMessage: { message: Draft.Message }, GotThreadDraftMessage: { message: Draft.Message }, GotActionMessage: { message: ActionMessage }, - UpdatedThreadMember: { memberId: Schema.NullOr(ChannelMemberId) }, + UpdatedThreadMember: { threadChannelId: ChannelId, memberId: Schema.NullOr(ChannelMemberId) }, + ClickedThreadPreview: { threadChannelId: ChannelId, messageId: MessageId }, + ClickedCloseThread: {}, ClickedGenerateThreadName: {}, ClickedRenameThread: {}, + ClickedJoinChannel: {}, /** A printable key typed outside any input (`useGlobalKeyboardFocus`). */ PressedGlobalKey: { key: Schema.String }, /** The window lost focus or the tab was hidden: typing stops. */ LeftWindow: {}, - SucceededClearNotifications: {}, - FailedClearNotifications: { reason: Schema.String }, + SucceededClearNotifications: { channelId: ChannelId }, + FailedClearNotifications: { channelId: ChannelId, reason: ClearNotificationsFailure }, }) export type Message = typeof Message.Type -export type PageReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc | HazelApiClient> - +export type PageReturn = Update.ReturnWithOutMessage< + Model, + Message, + PageOutMessage, + HazelRpc | HazelApiClient +> diff --git a/apps/web-foldkit/src/page/chat/channel/page.test.ts b/apps/web-foldkit/src/page/chat/channel/page.test.ts index 172183ca8..e6e0b2fd5 100644 --- a/apps/web-foldkit/src/page/chat/channel/page.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/page.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test, vi } from "vitest" import * as MessageList from "../../../mount/message-list" -import { init, Message, type Model, REMEMBERED_CHANNELS, switchChannel, update } from "./page" +import { init, Message, type Model, REMEMBERED_CHANNELS, setTab, switchChannel, update } from "./page" import type { ChatMessage } from "../rows" /** Update-loop tests for the channel page's list: prepend anchoring, following the end, paging. */ @@ -42,6 +42,10 @@ const chatMessage = (n: number): ChatMessage => ({ const page = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => chatMessage(to - index)) +/** The messages query's result as a change set: these messages, in this order. */ +const loaded = (messages: ReadonlyArray<ChatMessage>) => + Message.ChangedMessages({ order: messages.map((message) => message.id), upserts: messages }) + const ROW = 72 const HEADER = 40 const ESTIMATE = 80 @@ -71,11 +75,11 @@ const offsetOf = (current: Model, key: string) => { /** A channel with messages 70..99 loaded, measured, and scrolled 10px into message 80. */ const readingHistory = (): Model => { - let current = init(channelId, ada) + let current = init(channelId, "hazel", ada) const run = (next: Message) => { current = update(current, next).model } - run(Message.UpdatedMessages({ messages: page(70, 99) })) + run(loaded(page(70, 99))) run(listMessage(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT }))) run(completed(current.list.scrollVersion, current.list.scrollTop)) run(measureAll(current)) @@ -92,8 +96,8 @@ describe("channel page list", () => { test("starts at the end and follows it while the rows get measured", () => { story( update, - given(init(channelId, ada)), - message(Message.UpdatedMessages({ messages: page(70, 99) })), + given(init(channelId, "hazel", ada)), + message(loaded(page(70, 99))), Command.expectNone(), message(listMessage(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT }))), model((current) => { @@ -116,7 +120,7 @@ describe("channel page list", () => { story( update, given(before), - message(Message.UpdatedMessages({ messages: page(40, 99) })), + message(loaded(page(40, 99))), model((current) => { // 30 unmeasured rows land above the anchor at the estimated height. expect(current.list.scrollTop).toBe(before.list.scrollTop + 30 * ESTIMATE) @@ -128,7 +132,7 @@ describe("channel page list", () => { applied(before.list.scrollVersion + 1, before.list.scrollTop + 30 * ESTIMATE), ), // The new rows get measured at 72px: the anchor still does not move on screen. - message(measureAll(update(before, Message.UpdatedMessages({ messages: page(40, 99) })).model)), + message(measureAll(update(before, loaded(page(40, 99))).model)), model((current) => { expect(current.list.scrollTop).toBe(before.list.scrollTop + 30 * ROW) expect(offsetOf(current, anchorKey) - current.list.scrollTop).toBe(viewportTopBefore) @@ -152,16 +156,16 @@ describe("channel page list", () => { story( update, given(atTop), - message(Message.UpdatedMessages({ messages: page(40, 99) })), + message(loaded(page(40, 99))), model((current) => expect(current.list.scrollTop).toBe(30 * ESTIMATE)), Command.resolve(MessageList.ApplyScroll, applied(atTop.list.scrollVersion + 1, 30 * ESTIMATE)), ) }) test("renders LegendList's window: the viewport plus its draw distance, recalculated only past it", () => { - let before = init(channelId, ada) + let before = init(channelId, "hazel", ada) for (const next of [ - Message.UpdatedMessages({ messages: page(0, 199) }), + loaded(page(0, 199)), listMessage(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT })), ]) before = update(before, next).model @@ -185,7 +189,7 @@ describe("channel page list", () => { // Story requires Commands to resolve before the next Message, so this race is driven by hand. test("ignores scroll events that describe the DOM before a pending scroll was applied", () => { const before = readingHistory() - const prepended = update(before, Message.UpdatedMessages({ messages: page(40, 99) })) + const prepended = update(before, loaded(page(40, 99))) expect(prepended.commands?.map((command) => command.name)).toEqual(["ApplyScroll"]) const stale = update(prepended.model, listMessage(MessageList.Message.ScrolledList({ scrollTop: 3 }))) expect(stale.model).toBe(prepended.model) @@ -201,9 +205,9 @@ describe("channel page list", () => { }) test("sticks to the bottom when a new message arrives at the end", () => { - let atEnd = init(channelId, ada) + let atEnd = init(channelId, "hazel", ada) for (const next of [ - Message.UpdatedMessages({ messages: page(70, 99) }), + loaded(page(70, 99)), listMessage(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT })), ]) atEnd = update(atEnd, next).model @@ -215,7 +219,7 @@ describe("channel page list", () => { story( update, given(atEnd), - message(Message.UpdatedMessages({ messages: page(70, 100) })), + message(loaded(page(70, 100))), model((current) => { expect(current.list.anchor._tag).toBe("End") expect(current.list.scrollTop).toBe(endBefore + ESTIMATE) @@ -232,7 +236,7 @@ describe("channel page list", () => { story( update, given(before), - message(Message.UpdatedMessages({ messages: page(70, 100) })), + message(loaded(page(70, 100))), Command.expectNone(), model((current) => { expect(current.list.scrollTop).toBe(before.list.scrollTop) @@ -255,12 +259,70 @@ describe("channel page list", () => { }) }) +/** Messages 70..99 loaded and measured, following the end. */ +const followingEnd = (): Model => { + let atEnd = init(channelId, "hazel", ada) + for (const next of [ + loaded(page(70, 99)), + listMessage(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT })), + ]) + atEnd = update(atEnd, next).model + atEnd = update(atEnd, measureAll(atEnd)).model + return update(atEnd, completed(atEnd.list.scrollVersion, atEnd.list.scrollTop)).model +} + +describe("while the Files tab hides the list", () => { + test("a new message's scroll finds no list: nothing moves and no older page is requested", () => { + const onFiles = setTab(followingEnd(), "files") + story( + update, + given(onFiles), + message(loaded(page(70, 100))), + model((current) => expect(MessageList.isScrollPending(current.list)).toBe(true)), + Command.resolve( + MessageList.ApplyScroll, + MessageList.Message.SkippedApplyScroll({ version: onFiles.list.scrollVersion + 1 }), + ), + model((current) => { + expect(MessageList.isScrollPending(current.list)).toBe(false) + expect(current.list.anchor._tag).toBe("End") + expect(current.limit).toBe(onFiles.limit) + expect(current.offset).toBe(0) + }), + ) + }) + + test("never pages, even when the list reports being near the oldest message", () => { + const onFiles = setTab(followingEnd(), "files") + const result = update(onFiles, listMessage(MessageList.Message.ScrolledList({ scrollTop: 0 }))) + expect(result.model.limit).toBe(onFiles.limit) + }) + + test("coming back scrolls the new list element to where the reader was", () => { + const onFiles = setTab(followingEnd(), "files") + const arrived = update(onFiles, loaded(page(70, 100))).model + const skipped = update( + arrived, + listMessage(MessageList.Message.SkippedApplyScroll({ version: arrived.list.scrollVersion })), + ).model + const back = setTab(skipped, "messages") + const mounted = update( + back, + listMessage(MessageList.Message.MountedList({ scrollTop: 0, viewportHeight: VIEWPORT })), + ) + expect(mounted.model.list.anchor._tag).toBe("End") + expect(mounted.model.list.scrollTop).toBe(MessageList.maxScrollTop(mounted.model.list)) + expect(mounted.commands?.map((command) => command.name)).toEqual(["ApplyScroll"]) + expect(mounted.model.limit).toBe(onFiles.limit) + }) +}) + describe("switching channels", () => { const otherChannelId = Schema.decodeSync(ChannelId)(uuid(9)) test("starts the next channel fresh, with the viewport and this channel's heights handed on", () => { const reading = readingHistory() - const next = switchChannel(reading, otherChannelId, { tab: "messages", orgSlug: "hazel" }) + const next = switchChannel(reading, otherChannelId, { tab: "messages" }) expect(next.channelId).toBe(otherChannelId) expect(next.messages).toEqual([]) expect(next.hasLoadedMessages).toBe(false) @@ -273,13 +335,13 @@ describe("switching channels", () => { test("lays a revisited channel out at the heights it measured", () => { const reading = readingHistory() - const away = switchChannel(reading, otherChannelId, { tab: "messages", orgSlug: "hazel" }) - const back = switchChannel(away, channelId, { tab: "messages", orgSlug: "hazel" }) + const away = switchChannel(reading, otherChannelId, { tab: "messages" }) + const back = switchChannel(away, channelId, { tab: "messages" }) expect(back.list.measuredHeights).toEqual(reading.list.measuredHeights) expect(Object.keys(back.heightsByChannel)).toEqual([otherChannelId]) - const loaded = update(back, Message.UpdatedMessages({ messages: page(70, 99) })).model + const revisited = update(back, loaded(page(70, 99))).model // Measured rows, not estimates: the first frame already ends where the last visit did. - expect(MessageList.layoutOf(loaded.list).totalHeight).toBe( + expect(MessageList.layoutOf(revisited.list).totalHeight).toBe( MessageList.layoutOf(reading.list).totalHeight, ) }) @@ -287,10 +349,7 @@ describe("switching channels", () => { test("remembers a bounded number of channels", () => { const visited = Array.from({ length: REMEMBERED_CHANNELS + 5 }, (_, index) => Schema.decodeSync(ChannelId)(uuid(100 + index)), - ).reduce( - (current, id) => switchChannel(current, id, { tab: "messages", orgSlug: "hazel" }), - readingHistory(), - ) + ).reduce((current, id) => switchChannel(current, id, { tab: "messages" }), readingHistory()) expect(Object.keys(visited.heightsByChannel)).toHaveLength(REMEMBERED_CHANNELS) }) }) diff --git a/apps/web-foldkit/src/page/chat/channel/page.ts b/apps/web-foldkit/src/page/chat/channel/page.ts index 1835273ba..d1a9e4615 100644 --- a/apps/web-foldkit/src/page/chat/channel/page.ts +++ b/apps/web-foldkit/src/page/chat/channel/page.ts @@ -1,27 +1,45 @@ -import type { ChannelId, ChannelMemberId, MessageId, UserId } from "@hazel/schema" +import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { Record, Struct } from "effect" import { Command } from "foldkit" +import { modifyFields } from "foldkit/struct" import * as Live from "../../../chat/live-state" import * as Unfurl from "../../../chat/unfurl" -import { modifyFields } from "foldkit/struct" import * as Composer from "../../../composer/composer" import * as Draft from "../../../composer/draft" import * as MessageList from "../../../mount/message-list" import { AppRoute, hrefOf } from "../../../route" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { replyIdsOf, threadIdsOf, toDeriveContext, toDisplayRows, unfurlRequestsOf } from "../derive" +import { + replyIdsOf, + threadIdsOf, + toDeriveContext, + toDisplayRows, + toThreadRows, + unfurlRequestsOf, +} from "../derive" +import { typingNamesOf, typingUsersOf } from "../banners" import { emptyLookups, type Lookups } from "../lookups" import * as FilesPage from "../files/page" +import { JoinChannel } from "../message-actions" import * as Overlays from "../overlays" import { PAGE_SIZE } from "../queries" -import { applyMessageChanges, shareIds, shareKeys, shareMessages, shareStickyKeys } from "../rows" -import { type ChatTab, Message, type Model, type PageReturn } from "./model" +import { + applyMessageChanges, + type ChatMessage, + shareIds, + shareKeys, + shareMessages, + shareStickyKeys, +} from "../rows" +import { type ChatTab, Message, type Model, type PageReturn, Tab } from "./model" +import { loadWhenNearEdge } from "./window" import * as Write from "./write" /** Channel page (`routes/_app/$orgSlug/chat/$id.tsx` + `$id/index.tsx`), read path. */ -export { ChatTab, Message, Model, type PageReturn } from "./model" +export { ChatTab, Message, Model, type PageReturn, Tab, type ThreadPanel } from "./model" +export { MAX_WINDOW } from "./window" // INIT @@ -29,7 +47,6 @@ export const listId = (channelId: string) => `message-list-${channelId}` export interface InitOptions { readonly tab?: ChatTab - readonly orgSlug?: string /** What the previous channel's page hands on (`switchChannel`). */ readonly carry?: { readonly viewportHeight: number @@ -39,12 +56,13 @@ export interface InitOptions { export const init = ( channelId: ChannelId, + orgSlug: string, currentUserId: UserId | null, options: InitOptions = {}, ): Model => ({ channelId, - tab: options.tab ?? "messages", - orgSlug: options.orgSlug ?? null, + tab: tabFor(channelId, orgSlug, options.tab ?? "messages", Tab.Messages()), + orgSlug, currentUserId, channel: null, parentChannel: null, @@ -54,9 +72,9 @@ export const init = ( lookups: emptyLookups, members: null, pinned: [], - threadMessages: [], typing: [], typingNowMs: 0, + typingNames: [], replyIds: [], threadIds: [], unfurls: {}, @@ -72,13 +90,10 @@ export const init = ( measuredHeights: options.carry?.heightsByChannel[channelId] ?? {}, }), overlays: Overlays.init(), - files: filesFor(channelId, options.orgSlug ?? null, options.tab ?? "messages", null), draft: Draft.init(channelId, composerEditorId(channelId)), - threadDraft: null, - threadMemberId: null, - pendingThreadChannelId: null, - isGeneratingThreadName: false, - hasClearedNotifications: false, + threadPanel: null, + notificationsClear: "NotSent", + isJoining: false, heightsByChannel: Record.remove(options.carry?.heightsByChannel ?? {}, channelId), }) @@ -99,7 +114,7 @@ export const switchChannel = ( ...Record.remove(model.heightsByChannel, model.channelId), [model.channelId]: model.list.measuredHeights, }).slice(-REMEMBERED_CHANNELS) - return init(channelId, model.currentUserId, { + return init(channelId, model.orgSlug, model.currentUserId, { ...options, carry: { viewportHeight: model.list.viewportHeight, @@ -110,28 +125,27 @@ export const switchChannel = ( export const composerEditorId = (channelId: string) => `composer-${channelId}` -/** The Files Submodel for a tab: kept across `files` and `files/media`, dropped on Messages. - * It waits for the org slug, which its links need. */ -const filesFor = ( - channelId: ChannelId, - orgSlug: string | null, - tab: ChatTab, - previous: FilesPage.Model | null, -): FilesPage.Model | null => - tab === "messages" || orgSlug === null - ? null - : previous === null - ? FilesPage.init(channelId, orgSlug, tab) - : FilesPage.setView(previous, tab) +/** The tab for a route: the Files Submodel is kept across `files` and `files/media`, dropped on Messages. */ +const tabFor = (channelId: ChannelId, orgSlug: string, routeTab: ChatTab, previous: Tab): Tab => + routeTab === "messages" + ? previous._tag === "Messages" + ? previous + : Tab.Messages() + : Tab.Files({ + files: + previous._tag === "Files" + ? FilesPage.setView(previous.files, routeTab) + : FilesPage.init(channelId, orgSlug, routeTab), + }) + +/** The route tab the page shows (what the tab bar highlights). */ +export const routeTabOf = (tab: Tab): ChatTab => (tab._tag === "Messages" ? "messages" : tab.files.view) /** The route moved between this channel's tabs. */ -export const setTab = (model: Model, tab: ChatTab): Model => - model.tab === tab +export const setTab = (model: Model, routeTab: ChatTab): Model => + routeTabOf(model.tab) === routeTab ? model - : modifyFields(model, { - tab: () => tab, - files: (files) => filesFor(model.channelId, model.orgSlug, tab, files), - }) + : modifyFields(model, { tab: (tab) => tabFor(model.channelId, model.orgSlug, routeTab, tab) }) // ROUTES @@ -153,40 +167,56 @@ const liftList = (model: Model, result: MessageList.ListReturn): PageReturn => ( }) /** The derive context with the page's unfurls and live states. */ -export const deriveContextOf = (model: Model) => +export const deriveContextOf = (model: Pick<Model, "lookups" | "currentUserId" | "unfurls" | "liveStates">) => toDeriveContext(model.lookups, model.currentUserId ?? undefined, { unfurls: model.unfurls, liveStates: model.liveStates, }) +const threadMessagesOf = (model: Model): ReadonlyArray<ChatMessage> => model.threadPanel?.messages ?? [] + /** * Starts the unfurl fetches the loaded messages need and marks them loading (legacy runs one atom * query per URL or tweet id, shared by every message that shows it). */ -const requestUnfurls = (model: Model, messages: ReadonlyArray<Model["messages"][number]>) => { +const requestUnfurls = (model: Model, messages: ReadonlyArray<ChatMessage>): PageReturn => { const pending: Record<string, Unfurl.Unfurl> = {} const commands: Array<Command.Command<Message>> = [] for (const message of messages) for (const request of unfurlRequestsOf(message)) { if (request.key in model.unfurls || request.key in pending) continue - pending[request.key] = Unfurl.Unfurl.Loading() + pending[request.key] = Unfurl.loading const command: Command.Command<Unfurl.Message> = request.kind === "tweet" ? Unfurl.FetchTweet({ tweetId: request.value }) : Unfurl.FetchLinkPreview({ url: request.value }) - commands.push(...Command.mapMessages([command], (message) => Message.GotUnfurlMessage({ message }))) + commands.push( + ...Command.mapMessages([command], (message) => Message.GotUnfurlMessage({ message })), + ) } return commands.length === 0 ? { model, commands } : { model: modifyFields(model, { unfurls: Struct.assign(pending) }), commands } } +/** The thread panel's rows, re-derived from its messages and the page's lookups. */ +const withThreadRows = (model: Model): Model => { + const panel = model.threadPanel + if (panel === null) return model + const rows = toThreadRows(panel.messages, deriveContextOf(model), panel.rows) + return rows === panel.rows + ? model + : modifyFields(model, { threadPanel: () => modifyFields(panel, { rows: () => rows }) }) +} + /** Re-derives the rows, then tells the list about the new keys so it can keep its anchor. */ const deriveRows = (current: Model): PageReturn => { - const requested = requestUnfurls(current, [...current.messages, ...current.threadMessages]) - const model = requested.model + const requested = requestUnfurls(current, [...current.messages, ...threadMessagesOf(current)]) + const model = withThreadRows(requested.model) const rows = toDisplayRows(model.messages, model.reactions, deriveContextOf(model), model.rows) - const isShown = (messageId: MessageId) => rows.some((row) => row.key === messageId) + const threadRows = model.threadPanel?.rows ?? [] + const isShown = (messageId: MessageId) => + rows.some((row) => row.key === messageId) || threadRows.some((row) => row.key === messageId) const withRows = modifyFields(model, { rows: () => rows, overlays: (overlays) => Overlays.forgetMissingMessages(overlays, isShown), @@ -201,101 +231,95 @@ const deriveRows = (current: Model): PageReturn => { shareStickyKeys(withRows.list.stickyKeys, rows), ), ) - return { ...listed, commands: [...requested.commands, ...(listed.commands ?? [])] } + return { ...listed, commands: [...(requested.commands ?? []), ...(listed.commands ?? [])] } } /** The live reply ids, re-read only when the window's or the thread panel's messages change. */ const withLiveIds = (model: Model): Model => { - const liveIds = shareIds(model.liveIds, Live.connectedMessageIds([...model.messages, ...model.threadMessages])) + const liveIds = shareIds( + model.liveIds, + Live.connectedMessageIds([...model.messages, ...threadMessagesOf(model)]), + ) return liveIds === model.liveIds ? model : modifyFields(model, { liveIds: () => liveIds }) } -const withLookup = <K extends keyof Lookups>(model: Model, key: K, value: Lookups[K]): PageReturn => - deriveRows(modifyFields(model, { lookups: (lookups) => ({ ...lookups, [key]: value }) })) - -/** The most messages the window holds (S2 condition 1); past it, older pages slide the window. */ -export const MAX_WINDOW = 10 * PAGE_SIZE - -/** Grows the window by a page near the oldest message, then slides it; near the end it slides back. */ -const loadWhenNearEdge = (result: PageReturn): PageReturn => { - const { model } = result - const isPageFull = model.messages.length >= model.limit - if (isPageFull && MessageList.isNearStart(model.list)) { - const next = - model.limit < MAX_WINDOW - ? modifyFields(model, { limit: (limit) => limit + PAGE_SIZE }) - : modifyFields(model, { offset: (offset) => offset + PAGE_SIZE }) - return { ...result, model: withWindowFollow(next) } - } - if (model.offset > 0 && MessageList.isNearEnd(model.list)) - return { - ...result, - model: withWindowFollow(modifyFields(model, { offset: (offset) => Math.max(0, offset - PAGE_SIZE) })), - } - return result -} - -/** Only a window that reaches the newest message follows the end. */ -const withWindowFollow = (model: Model): Model => { - const list = MessageList.setCanFollowEnd(model.list, model.offset === 0) - return list === model.list ? model : modifyFields(model, { list: () => list }) +/** Who is typing, re-read when the indicators, members, users or the clock change. */ +const withTypingNames = (model: Model): Model => { + const typingNames = typingNamesOf( + model.typingNames, + typingUsersOf( + model.typing, + model.members, + model.lookups.users, + model.currentUserId, + model.typingNowMs, + ), + ) + return typingNames === model.typingNames ? model : modifyFields(model, { typingNames: () => typingNames }) } -/** Back to the newest page (after sending from a scrolled-back window). */ -export const resetWindow = (model: Model): Model => - model.offset === 0 ? model : withWindowFollow(modifyFields(model, { offset: () => 0, limit: () => PAGE_SIZE })) +const withLookup = <K extends keyof Lookups>(model: Model, key: K, value: Lookups[K]): PageReturn => + deriveRows(modifyFields(model, { lookups: (lookups) => ({ ...lookups, [key]: value }) })) const liftOverlays = (model: Model, result: Overlays.OverlaysReturn): PageReturn => { - const synced = - result.model === model.overlays - ? { model } - : Write.syncThreadDraft(modifyFields(model, { overlays: () => result.model })) - const next = synced.model - const commands = [ - ...Command.mapMessages(result.commands ?? [], (message) => Message.GotOverlaysMessage({ message })), - ...(synced.commands ?? []), - ] - if (result.outMessage === undefined) return { model: next, commands } - const handled = Write.handleOverlaysOut(next, result.outMessage) - return { ...handled, commands: [...commands, ...(handled.commands ?? [])] } + const next = + result.model === model.overlays ? model : modifyFields(model, { overlays: () => result.model }) + const lifted: PageReturn = { + model: next, + commands: Command.mapMessages(result.commands ?? [], (message) => + Message.GotOverlaysMessage({ message }), + ), + } + return result.outMessage === undefined + ? lifted + : Write.andThen(lifted, (current) => Write.handleOverlaysOut(current, result.outMessage!)) } -/** Runs a second step on the first's Model, keeping both steps' commands. */ -const combine = (first: PageReturn, second: (model: Model) => PageReturn): PageReturn => { - const next = second(first.model) - return { - ...next, - commands: [...(first.commands ?? []), ...(next.commands ?? [])], - ...(first.outMessage === undefined || next.outMessage !== undefined ? {} : { outMessage: first.outMessage }), +const liftFiles = (model: Model, result: FilesPage.FilesReturn): PageReturn => { + const lifted: PageReturn = { + model: modifyFields(model, { tab: () => Tab.Files({ files: result.model }) }), + commands: Command.mapMessages(result.commands ?? [], (message) => + Message.GotFilesMessage({ message }), + ), } + return result.outMessage === undefined + ? lifted + : Write.andThen(lifted, (current) => + FilesPage.OutMessage.match<PageReturn>(result.outMessage!, { + RequestedViewerAction: ({ action, url, fileName }) => + Write.viewerAction(current, action, url, fileName), + }), + ) } +/** `ChannelJoinBanner`'s `handleJoin`. */ +const joinChannel = (model: Model): PageReturn => + model.isJoining || model.currentUserId === null + ? { model } + : { + model: modifyFields(model, { isJoining: () => true }), + commands: Write.actionCommands([ + JoinChannel({ channelId: model.channelId, userId: model.currentUserId }), + ]), + } + export const update = (model: Model, message: Message, shared: Shared | null = null): PageReturn => Message.match<PageReturn>(message, { UpdatedChannel: ({ channel }) => ({ model: modifyFields(model, { channel: () => channel }) }), - UpdatedOrgSlug: ({ orgSlug }) => - orgSlug === null || model.orgSlug !== null + ClickedTab: ({ tab }) => + tab === routeTabOf(model.tab) ? { model } : { - model: modifyFields(model, { - orgSlug: () => orgSlug, - files: (files) => filesFor(model.channelId, orgSlug, model.tab, files), + model, + outMessage: PageOutMessage.RequestedNavigation({ + href: tabPath(model.orgSlug, model.channelId, tab), + replace: false, }), }, - // Navigation is an OutMessage, raised by the page definition (`index.ts`). - ClickedTab: () => ({ model }), + ClickedMobileMenu: () => ({ model, outMessage: PageOutMessage.RequestedMobileSidebar() }), UpdatedParentChannel: ({ channel }) => ({ model: modifyFields(model, { parentChannel: () => channel }), }), - UpdatedMessages: ({ messages }) => - deriveRows( - withLiveIds( - modifyFields(model, { - hasLoadedMessages: () => true, - messages: (previous) => shareMessages(previous, messages), - }), - ), - ), ChangedMessages: ({ order, upserts }) => deriveRows( withLiveIds( @@ -306,75 +330,112 @@ export const update = (model: Model, message: Message, shared: Shared | null = n ), ), UpdatedReactions: ({ reactions }) => deriveRows(modifyFields(model, { reactions: () => reactions })), - UpdatedUsers: ({ users }) => withLookup(model, "users", users), + UpdatedUsers: ({ users }) => { + const result = withLookup(model, "users", users) + return { ...result, model: withTypingNames(result.model) } + }, UpdatedPresence: ({ presence }) => - combine( - withLookup(model, "presence", presence), - (next) => Write.forwardComposerData(next, Composer.Message.UpdatedPresence({ presence: Write.toComposerPresence(presence) })), + Write.andThen(withLookup(model, "presence", presence), (next) => + Write.forwardComposerData( + next, + Composer.Message.UpdatedPresence({ presence: Write.toComposerPresence(presence) }), + ), ), UpdatedBots: ({ bots }) => withLookup(model, "bots", bots), UpdatedCustomEmojis: ({ customEmojis }) => - combine( - withLookup(model, "customEmojis", customEmojis), - (next) => Write.forwardComposerData(next, Composer.Message.UpdatedCustomEmojis({ emojis: customEmojis })), + Write.andThen(withLookup(model, "customEmojis", customEmojis), (next) => + Write.forwardComposerData( + next, + Composer.Message.UpdatedCustomEmojis({ emojis: customEmojis }), + ), ), UpdatedAttachments: ({ attachments }) => withLookup(model, "attachments", attachments), UpdatedDiscordSynced: ({ messageIds }) => withLookup(model, "discordSyncedIds", messageIds), UpdatedThreadChannels: ({ channels }) => withLookup(model, "threadChannels", channels), UpdatedThreadMessages: ({ messages }) => withLookup(model, "threadMessages", messages), UpdatedReplyTargets: ({ targets }) => withLookup(model, "replyTargets", targets), - UpdatedMembers: ({ members }) => ({ model: modifyFields(model, { members: () => members }) }), + UpdatedMembers: ({ members }) => ({ + model: withTypingNames(modifyFields(model, { members: () => members })), + }), UpdatedPinned: ({ pins }) => ({ model: modifyFields(model, { pinned: () => pins }) }), - UpdatedThreadPanelMessages: ({ messages }) => - requestUnfurls( - withLiveIds(modifyFields(model, { threadMessages: (previous) => shareMessages(previous, messages) })), - messages, - ), - UpdatedTyping: ({ typing }) => ({ model: modifyFields(model, { typing: () => typing }) }), - TickedTypingClock: ({ nowMs }) => ({ model: modifyFields(model, { typingNowMs: () => nowMs }) }), + // A result for a thread the panel no longer shows is dropped. + UpdatedThreadPanelMessages: ({ threadChannelId, messages }) => { + const panel = model.threadPanel + if (panel === null || panel.threadChannelId !== threadChannelId) return { model } + const withMessages = withLiveIds( + modifyFields(model, { + threadPanel: () => + modifyFields(panel, { messages: (previous) => shareMessages(previous, messages) }), + }), + ) + const requested = requestUnfurls(withMessages, messages) + return { ...requested, model: withThreadRows(requested.model) } + }, + UpdatedTyping: ({ typing }) => ({ + model: withTypingNames(modifyFields(model, { typing: () => typing })), + }), + TickedTypingClock: ({ nowMs }) => ({ + model: withTypingNames(modifyFields(model, { typingNowMs: () => nowMs })), + }), GotUnfurlMessage: ({ message: unfurlMessage }) => - deriveRows(modifyFields(model, { unfurls: (unfurls) => Unfurl.applyMessage(unfurls, unfurlMessage) })), + deriveRows( + modifyFields(model, { unfurls: (unfurls) => Unfurl.applyMessage(unfurls, unfurlMessage) }), + ), GotLiveMessage: ({ message: liveMessage }) => - deriveRows(modifyFields(model, { liveStates: (states) => Live.applyMessage(states, liveMessage) })), + deriveRows( + modifyFields(model, { liveStates: (states) => Live.applyMessage(states, liveMessage) }), + ), GotListMessage: ({ message: listMessage }) => loadWhenNearEdge(liftList(model, MessageList.update(model.list, listMessage))), GotOverlaysMessage: ({ message: overlaysMessage }) => liftOverlays(model, Overlays.update(model.overlays, overlaysMessage, factsOf(model))), - // Reported to the root as `RequestedMobileSidebar` by `index.ts`. - ClickedMobileMenu: () => ({ model }), - GotDraftMessage: ({ message: draftMessage }) => Write.updateDraft(model, "channel", draftMessage, shared), - GotThreadDraftMessage: ({ message: draftMessage }) => Write.updateDraft(model, "thread", draftMessage, shared), + GotDraftMessage: ({ message: draftMessage }) => + Write.updateDraft(model, "channel", draftMessage, shared), + GotThreadDraftMessage: ({ message: draftMessage }) => + Write.updateDraft(model, "thread", draftMessage, shared), GotActionMessage: ({ message: actionMessage }) => Write.handleActionMessage(model, actionMessage), - UpdatedThreadMember: ({ memberId }) => ({ model: modifyFields(model, { threadMemberId: () => memberId }) }), + UpdatedThreadMember: ({ threadChannelId, memberId }) => + model.threadPanel?.threadChannelId === threadChannelId + ? { + model: modifyFields(model, { + threadPanel: (panel) => + panel === null ? null : modifyFields(panel, { memberId: () => memberId }), + }), + } + : { model }, + ClickedThreadPreview: ({ threadChannelId, messageId }) => + Write.openThread(model, threadChannelId, messageId, "Created"), + ClickedCloseThread: () => Write.closeThread(model), ClickedGenerateThreadName: () => Write.generateThreadName(model), ClickedRenameThread: () => - model.overlays.thread === null + model.threadPanel === null ? { model } : { model, outMessage: PageOutMessage.RequestedModal({ - modal: { _tag: "RenameThread", threadId: model.overlays.thread.threadChannelId }, + modal: { _tag: "RenameThread", threadId: model.threadPanel.threadChannelId }, }), }, + ClickedJoinChannel: () => joinChannel(model), PressedGlobalKey: ({ key }) => Write.insertGlobalKey(model, key), - LeftWindow: () => { - const channel = Write.updateDraft(model, "channel", Draft.Message.LeftWindow(), shared) - if (channel.model.threadDraft === null) return channel - const thread = Write.updateDraft(channel.model, "thread", Draft.Message.LeftWindow(), shared) - return { ...thread, commands: [...(channel.commands ?? []), ...(thread.commands ?? [])] } - }, - SucceededClearNotifications: () => ({ model }), - FailedClearNotifications: () => ({ model }), - GotFilesMessage: ({ message: filesMessage }) => { - if (model.files === null) return { model } - const result = FilesPage.update(model.files, filesMessage) - return { - model: modifyFields(model, { files: () => result.model }), - commands: Command.mapMessages(result.commands ?? [], (message) => - Message.GotFilesMessage({ message }), - ), - } - }, + LeftWindow: () => + Write.andThen(Write.updateDraft(model, "channel", Draft.Message.LeftWindow(), shared), (next) => + Write.updateDraft(next, "thread", Draft.Message.LeftWindow(), shared), + ), + // A result for the channel the page has since switched away from is dropped. + SucceededClearNotifications: ({ channelId }) => + channelId === model.channelId + ? { model: modifyFields(model, { notificationsClear: () => "Cleared" }) } + : { model }, + // Best effort, like legacy: no toast, and the next shared change sends it again. + FailedClearNotifications: ({ channelId }) => + channelId === model.channelId + ? { model: modifyFields(model, { notificationsClear: () => "NotSent" }) } + : { model }, + GotFilesMessage: ({ message: filesMessage }) => + model.tab._tag === "Files" + ? liftFiles(model, FilesPage.update(model.tab.files, filesMessage)) + : { model }, }) /** Message facts the overlays read when a menu opens. */ diff --git a/apps/web-foldkit/src/page/chat/channel/read.story.test.ts b/apps/web-foldkit/src/page/chat/channel/read.story.test.ts index fb55d3d17..1f3dc0401 100644 --- a/apps/web-foldkit/src/page/chat/channel/read.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/read.story.test.ts @@ -7,14 +7,22 @@ import * as Unfurl from "../../../chat/unfurl" import * as Draft from "../../../composer/draft" import * as Typing from "../../../composer/typing" import * as MessageList from "../../../mount/message-list" -import type { ToastRequest } from "../../../overlay/toasts" import { chatMessageOf, imageAttachmentOf, liveEmbeds } from "../../../test/chat-messages" import { PageOutMessage } from "../../out-message" -import { ActionMessage, CopyText, OpenUrl } from "../message-actions" +import { ActionMessage, CopyText, GenerateThreadName, OpenUrl } from "../message-actions" import * as Overlays from "../overlays" import { PAGE_SIZE } from "../queries" import type { DisplayRow, MessageRow } from "../rows" -import { ada, adaMessageId, grace, graceMessageId, loadedModel, messages, updateWithShared } from "./fixtures.test-support" +import { + ada, + adaMessageId, + grace, + graceMessageId, + loadedMessages, + loadedModel, + messages, + updateWithShared, +} from "./fixtures.test-support" import { Message, type Model } from "./model" import { MAX_WINDOW, setTab } from "./page" @@ -23,7 +31,9 @@ import { MAX_WINDOW, setTab } from "./page" // The Files tab's `ui/aria/interaction` and ProseMirror's view read `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 overlays = (inner: Overlays.Message) => Message.GotOverlaysMessage({ message: inner }) @@ -32,12 +42,13 @@ const draft = (inner: Draft.Message) => Message.GotDraftMessage({ message: inner const threadDraft = (inner: Draft.Message) => Message.GotThreadDraftMessage({ message: inner }) const isMessageRow = (row: DisplayRow): row is MessageRow => row._tag === "MessageRow" -const rowOf = (current: Model, key: string) => current.rows.filter(isMessageRow).find((row) => row.key === key) +const rowOf = (current: Model, key: string) => + current.rows.filter(isMessageRow).find((row) => row.key === key) const messageKeys = (current: Model) => current.rows.filter(isMessageRow).map((row) => row.key) /** The loaded page with its rows derived, as the first messages query answer leaves it. */ const derived = (base: Model = loadedModel()): Model => - updateWithShared(base, Message.UpdatedMessages({ messages: base.messages })).model + updateWithShared(base, loadedMessages(base.messages)).model const threadChannelId = Schema.decodeSync(ChannelId)("00000000-0000-4000-8000-000000004444") @@ -48,7 +59,12 @@ describe("change sets", () => { story( updateWithShared, given(before), - message(Message.ChangedMessages({ order: [fresh.id, adaMessageId, graceMessageId], upserts: [fresh] })), + message( + Message.ChangedMessages({ + order: [fresh.id, adaMessageId, graceMessageId], + upserts: [fresh], + }), + ), model((current) => { expect(messageKeys(current)).toEqual([graceMessageId, adaMessageId, fresh.id]) expect(current.messages[1]).toBe(before.messages[0]) @@ -64,7 +80,10 @@ describe("change sets", () => { message( Message.ChangedMessages({ order: [adaMessageId, graceMessageId], - upserts: adaMessage === undefined ? [] : [{ ...adaMessage, content: "Shipped v2", updatedAtMs: 1 }], + upserts: + adaMessage === undefined + ? [] + : [{ ...adaMessage, content: "Shipped v2", updatedAtMs: 1 }], }), ), model((current) => expect(rowOf(current, adaMessageId)?.message.content).toBe("Shipped v2")), @@ -81,7 +100,9 @@ describe("change sets", () => { story( updateWithShared, given(before), - message(Message.ChangedMessages({ order: [adaMessageId, graceMessageId], upserts: [...messages] })), + message( + Message.ChangedMessages({ order: [adaMessageId, graceMessageId], upserts: [...messages] }), + ), model((current) => { expect(current.messages).toBe(before.messages) expect(current.list.keys).toBe(before.list.keys) @@ -134,7 +155,10 @@ describe("message window", () => { // The newest loaded row is no longer the newest message: the list must not follow the end. expect(current.list.canFollowEnd).toBe(false) }), - Command.resolve(MessageList.WaitForScrollSettle, MessageList.Message.CompletedWaitForScrollSettle({ version: 1 })), + Command.resolve( + MessageList.WaitForScrollSettle, + MessageList.Message.CompletedWaitForScrollSettle({ version: 1 }), + ), message(draft(Draft.Message.SucceededSendMessage())), model((current) => { expect(current.offset).toBe(0) @@ -154,7 +178,12 @@ describe("message window", () => { const atEnd = (): Model => { const steps: ReadonlyArray<Message> = [ list(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT })), - list(MessageList.Message.CompletedApplyScroll({ version: 1, scrollTop: endScrollTopFor(PAGE_SIZE) })), + list( + MessageList.Message.CompletedApplyScroll({ + version: 1, + scrollTop: endScrollTopFor(PAGE_SIZE), + }), + ), ] return steps.reduce( (current, next) => updateWithShared(current, next).model, @@ -194,7 +223,12 @@ describe("message window", () => { updateWithShared, given(current), message( - list(MessageList.Message.MountedList({ scrollTop: current.list.scrollTop, viewportHeight: VIEWPORT })), + list( + MessageList.Message.MountedList({ + scrollTop: current.list.scrollTop, + viewportHeight: VIEWPORT, + }), + ), ), Command.expectNone(), ) @@ -218,9 +252,9 @@ describe("link unfurls", () => { ), Command.expectExact(Unfurl.FetchLinkPreview({ url })), model((current) => { - expect(current.unfurls[Unfurl.linkPreviewKey(url)]).toEqual(Unfurl.Unfurl.Loading()) + expect(current.unfurls[Unfurl.linkPreviewKey(url)]).toEqual(Unfurl.loading) expect(rowOf(current, first.id)?.urlEmbeds).toEqual([ - expect.objectContaining({ _tag: "LinkPreview", url, unfurl: Unfurl.Unfurl.Loading() }), + expect.objectContaining({ _tag: "LinkPreview", url, unfurl: Unfurl.loading }), ]) }), Command.resolve( @@ -228,9 +262,11 @@ describe("link unfurls", () => { Unfurl.Message.SucceededFetchLinkPreview({ url, preview: { title: "Launch" } }), ), model((current) => { - const loaded = Unfurl.Unfurl.LoadedLinkPreview({ preview: { title: "Launch" } }) - expect(rowOf(current, first.id)?.urlEmbeds).toEqual([expect.objectContaining({ unfurl: loaded })]) - expect(rowOf(current, second.id)?.urlEmbeds).toEqual([expect.objectContaining({ unfurl: loaded })]) + const unfurl = rowOf(current, first.id)?.urlEmbeds[0] + expect(unfurl?._tag === "LinkPreview" && Unfurl.linkPreviewOf(unfurl.unfurl)).toEqual({ + title: "Launch", + }) + expect(rowOf(current, second.id)?.urlEmbeds).toEqual(rowOf(current, first.id)?.urlEmbeds) }), ) }) @@ -240,9 +276,22 @@ describe("link unfurls", () => { story( updateWithShared, given(derived()), - message(Message.ChangedMessages({ order: [linked.id, adaMessageId, graceMessageId], upserts: [linked] })), - Command.resolve(Unfurl.FetchLinkPreview, Unfurl.Message.FailedFetchLinkPreview({ url })), - model((current) => expect(current.unfurls[Unfurl.linkPreviewKey(url)]).toEqual(Unfurl.Unfurl.Failed())), + message( + Message.ChangedMessages({ + order: [linked.id, adaMessageId, graceMessageId], + upserts: [linked], + }), + ), + Command.resolve( + Unfurl.FetchLinkPreview, + Unfurl.Message.FailedFetchLinkPreview({ url, reason: "TimedOut" }), + ), + model((current) => + expect(current.unfurls[Unfurl.linkPreviewKey(url)]).toEqual({ + _tag: "Failure", + error: "TimedOut", + }), + ), message(Message.UpdatedReactions({ reactions: [] })), Command.expectNone(), ) @@ -253,9 +302,17 @@ describe("link unfurls", () => { story( updateWithShared, given(derived()), - message(Message.ChangedMessages({ order: [tweet.id, adaMessageId, graceMessageId], upserts: [tweet] })), + message( + Message.ChangedMessages({ + order: [tweet.id, adaMessageId, graceMessageId], + upserts: [tweet], + }), + ), Command.expectExact(Unfurl.FetchTweet({ tweetId: "1234567890" })), - Command.resolve(Unfurl.FetchTweet, Unfurl.Message.FailedFetchTweet({ tweetId: "1234567890" })), + Command.resolve( + Unfurl.FetchTweet, + Unfurl.Message.FailedFetchTweet({ tweetId: "1234567890", reason: "Unavailable" }), + ), ) }) }) @@ -273,7 +330,11 @@ describe("AI reply stream", () => { message(received(Live.LiveEvent.Started())), message(received(Live.LiveEvent.TextChunk({ fullText: "Hel" }))), model((current) => - expect(rowOf(current, ai.id)?.live?.state).toMatchObject({ status: "active", text: "Hel", isStreaming: true }), + expect(rowOf(current, ai.id)?.live?.state).toMatchObject({ + status: "active", + text: "Hel", + isStreaming: true, + }), ), message(received(Live.LiveEvent.StreamEnd({ text: "Hello" }))), message(received(Live.LiveEvent.Completed())), @@ -295,80 +356,155 @@ describe("AI reply stream", () => { given(derived({ ...loadedModel(), messages: [ai, ...messages] })), message(received(Live.LiveEvent.Failed({ error: "Agent crashed" }))), model((current) => - expect(rowOf(current, ai.id)?.live?.state).toMatchObject({ status: "failed", error: "Agent crashed" }), + expect(rowOf(current, ai.id)?.live?.state).toMatchObject({ + status: "failed", + error: "Agent crashed", + }), ), ) }) }) describe("threads", () => { + const preview = Message.ClickedThreadPreview({ threadChannelId, messageId: graceMessageId }) + const otherThreadId = Schema.decodeSync(ChannelId)("00000000-0000-4000-8000-000000005555") + const action = (inner: ActionMessage) => Message.GotActionMessage({ message: inner }) + test("a thread preview opens the panel with its own draft; closing drops it", () => { story( updateWithShared, given(derived()), - message(overlays(Overlays.Message.ClickedThreadPreview({ threadChannelId, messageId: graceMessageId }))), + message(preview), model((current) => { - expect(current.overlays.thread).toEqual({ threadChannelId, messageId: graceMessageId }) - expect(current.threadDraft?.channelId).toBe(threadChannelId) - }), - message(overlays(Overlays.Message.ClosedThread())), - model((current) => { - expect(current.overlays.thread).toBeNull() - expect(current.threadDraft).toBeNull() + expect(current.threadPanel).toMatchObject({ + threadChannelId, + messageId: graceMessageId, + creation: "Created", + }) + expect(current.threadPanel?.draft.channelId).toBe(threadChannelId) }), + message(Message.ClickedCloseThread()), + model((current) => expect(current.threadPanel).toBeNull()), + ) + }) + + test("the panel's replies become keyed rows in update; a result for another thread is dropped", () => { + const reply = chatMessageOf(30, { channelId: threadChannelId, content: "On it" }) + const opened = updateWithShared(derived(), preview).model + story( + updateWithShared, + given(opened), + message(Message.UpdatedThreadPanelMessages({ threadChannelId, messages: [reply] })), + model((current) => expect(current.threadPanel?.rows.map((row) => row.key)).toEqual([reply.id])), + message(Message.UpdatedThreadPanelMessages({ threadChannelId: otherThreadId, messages: [] })), + model((current) => expect(current.threadPanel?.rows.map((row) => row.key)).toEqual([reply.id])), ) }) - test("a failed thread creation closes the panel and toasts", () => { - const toast: ToastRequest = { intent: "error", title: "Could not create thread", description: null } + test("a failed thread creation closes the panel and toasts the reason's copy", () => { const opened = updateWithShared( derived(), - Message.GotActionMessage({ - message: ActionMessage.CompletedGenerateThreadChannelId({ messageId: graceMessageId, threadChannelId }), - }), + action( + ActionMessage.CompletedGenerateThreadChannelId({ + messageId: graceMessageId, + threadChannelId, + }), + ), ).model story( updateWithShared, given(opened), - message(Message.GotActionMessage({ message: ActionMessage.FailedCreateThread({ threadChannelId, toast }) })), - expectOutMessage(PageOutMessage.RequestedToast({ toast })), - model((current) => { - expect(current.overlays.thread).toBeNull() - expect(current.pendingThreadChannelId).toBeNull() - }), + message(action(ActionMessage.FailedCreateThread({ threadChannelId, reason: "NestedThread" }))), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Cannot create thread", + description: "Threads cannot be created within threads", + }, + }), + ), + model((current) => expect(current.threadPanel).toBeNull()), ) }) + // Thread A is still being created when the reader opens thread B. + test("creating or naming thread A never settles thread B's panel", () => { + const creatingA = updateWithShared( + derived(), + action( + ActionMessage.CompletedGenerateThreadChannelId({ + messageId: graceMessageId, + threadChannelId, + }), + ), + ).model + const onB = updateWithShared( + creatingA, + Message.ClickedThreadPreview({ threadChannelId: otherThreadId, messageId: adaMessageId }), + ).model + const creatingB = { + ...onB, + threadPanel: + onB.threadPanel === null ? null : { ...onB.threadPanel, creation: "Creating" as const }, + } + story( + updateWithShared, + given(creatingB), + message(action(ActionMessage.SucceededCreateThread({ threadChannelId }))), + model((current) => expect(current.threadPanel?.creation).toBe("Creating")), + message(action(ActionMessage.FailedCreateThread({ threadChannelId, reason: "Offline" }))), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Connection lost", + description: "Check your internet connection and try again.", + }, + }), + ), + model((current) => expect(current.threadPanel?.threadChannelId).toBe(otherThreadId)), + message(Message.ClickedGenerateThreadName()), + Command.resolve( + GenerateThreadName, + ActionMessage.SucceededGenerateThreadName({ threadChannelId: otherThreadId }), + ), + model((current) => expect(current.threadPanel?.isNaming).toBe(false)), + ) + const namingB = updateWithShared(onB, Message.ClickedGenerateThreadName()).model + expect(namingB.threadPanel?.isNaming).toBe(true) + const lateA = updateWithShared( + namingB, + action(ActionMessage.SucceededGenerateThreadName({ threadChannelId })), + ) + expect(lateA.model.threadPanel?.isNaming).toBe(true) + }) + const indicatorId = Schema.decodeSync(TypingIndicatorId)("00000000-0000-4000-8000-000000009999") // Legacy `useTyping` deletes the indicator when the panel's composer unmounts. test("closing the thread panel while typing deletes the thread's typing indicator", () => { - const opened = updateWithShared( - derived(), - overlays(Overlays.Message.ClickedThreadPreview({ threadChannelId, messageId: graceMessageId })), - ).model - const typing = opened.threadDraft === null ? null : { ...opened.threadDraft.typing, isTyping: true, sessionId: 1, indicatorId } + const opened = updateWithShared(derived(), preview).model + const panel = opened.threadPanel! + const typing = { ...panel.draft.typing, isTyping: true, sessionId: 1, indicatorId } story( updateWithShared, - given({ ...opened, threadDraft: opened.threadDraft === null || typing === null ? null : { ...opened.threadDraft, typing } }), - message(overlays(Overlays.Message.ClosedThread())), + given({ ...opened, threadPanel: { ...panel, draft: { ...panel.draft, typing } } }), + message(Message.ClickedCloseThread()), Command.expectExact(Typing.DeleteTypingIndicator({ id: indicatorId })), Command.resolve(Typing.DeleteTypingIndicator, Typing.Message.CompletedDeleteTypingIndicator()), - model((current) => expect(current.threadDraft).toBeNull()), + model((current) => expect(current.threadPanel).toBeNull()), ) }) test("thread typing heartbeats go through the thread draft", () => { - const opened = updateWithShared( - derived(), - overlays(Overlays.Message.ClickedThreadPreview({ threadChannelId, messageId: graceMessageId })), - ).model + const opened = updateWithShared(derived(), preview).model story( updateWithShared, given(opened), message(threadDraft(Draft.Message.LeftWindow())), Command.expectNone(), - model((current) => expect(current.threadDraft?.typing.isTyping).toBe(false)), + model((current) => expect(current.threadPanel?.draft.typing.isTyping).toBe(false)), ) }) }) @@ -388,14 +524,22 @@ describe("image viewer", () => { story( updateWithShared, given(withImages()), - message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 1 }))), + message( + overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 1 })), + ), model((current) => - expect(current.overlays.imageViewer).toEqual({ messageId: graceMessageId, index: 1, urlImages: null }), + expect(Overlays.imageViewerOf(current.overlays)).toEqual( + Overlays.OpenOverlay.ImageViewer({ + messageId: graceMessageId, + index: 1, + urlImages: null, + }), + ), ), message(overlays(Overlays.Message.SelectedViewerImage({ index: 0 }))), - model((current) => expect(current.overlays.imageViewer?.index).toBe(0)), + model((current) => expect(Overlays.imageViewerOf(current.overlays)?.index).toBe(0)), message(overlays(Overlays.Message.ClosedImageViewer())), - model((current) => expect(current.overlays.imageViewer).toBeNull()), + model((current) => expect(Overlays.imageViewerOf(current.overlays)).toBeNull()), ) }) @@ -404,17 +548,33 @@ describe("image viewer", () => { story( updateWithShared, given(withImages()), - message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 }))), - message(overlays(Overlays.Message.ClickedViewerAction({ action: "copyUrl", url, fileName: "photo-1.png" }))), - Command.expectExact(CopyText({ text: url })), + message( + overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 })), + ), + message( + overlays( + Overlays.Message.ClickedViewerAction({ action: "copyUrl", url, fileName: "photo-1.png" }), + ), + ), + Command.expectExact(CopyText({ text: url, content: "ImageUrl" })), + Command.resolve(CopyText, ActionMessage.SucceededCopyText({ content: "ImageUrl" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "success", title: "URL copied", description: "Image URL has been copied to clipboard." }, + toast: { + intent: "success", + title: "URL copied", + description: "Image URL has been copied to clipboard.", + }, }), ), - Command.resolve(CopyText, ActionMessage.CompletedCopyText()), message( - overlays(Overlays.Message.ClickedViewerAction({ action: "openInBrowser", url, fileName: "photo-1.png" })), + overlays( + Overlays.Message.ClickedViewerAction({ + action: "openInBrowser", + url, + fileName: "photo-1.png", + }), + ), ), Command.expectExact(OpenUrl({ url })), Command.resolve(OpenUrl, ActionMessage.CompletedOpenUrl()), @@ -426,10 +586,12 @@ describe("image viewer", () => { updateWithShared, given(withImages()), message(overlays(Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }))), - message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 }))), + message( + overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 })), + ), message(overlays(Overlays.Message.PointerLeftList())), Command.expectNone(), - model((current) => expect(current.overlays.hoveredMessageId).toBe(graceMessageId)), + model((current) => expect(Overlays.hoveredMessageIdOf(current.overlays)).toBe(graceMessageId)), ) }) @@ -440,18 +602,26 @@ describe("image viewer", () => { Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), facts, ).model - const within = Overlays.update(entered, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), facts) + const within = Overlays.update( + entered, + Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), + facts, + ) expect(within.model).toBe(entered) const left = Overlays.update(entered, Overlays.Message.PointerLeftList(), facts).model - expect(left.isHidePending).toBe(true) - const back = Overlays.update(left, Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), facts).model - expect(back.isHidePending).toBe(false) + expect(left.hover._tag).toBe("HidePending") + const back = Overlays.update( + left, + Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }), + facts, + ).model + expect(back.hover._tag).toBe("Shown") const stale = Overlays.update( back, - Overlays.Message.CompletedWaitForHideToolbar({ version: left.hoverVersion }), + Overlays.Message.CompletedWaitForHideToolbar({ version: left.hideVersion }), facts, ).model - expect(stale.hoveredMessageId).toBe(graceMessageId) + expect(Overlays.hoveredMessageIdOf(stale)).toBe(graceMessageId) }) // The view renders nothing for a viewer whose message left the window, so the viewer closes; it @@ -460,9 +630,40 @@ describe("image viewer", () => { story( updateWithShared, given(withImages()), - message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 }))), + message( + overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 })), + ), message(Message.ChangedMessages({ order: [adaMessageId], upserts: [] })), - model((current) => expect(current.overlays.imageViewer).toBeNull()), + model((current) => expect(Overlays.imageViewerOf(current.overlays)).toBeNull()), + ) + }) + + test("an image in a thread reply opens the viewer, which survives the channel's rows changing", () => { + const reply = chatMessageOf(30, { channelId: threadChannelId, content: "Screenshot" }) + const opened = updateWithShared( + withImages(), + Message.ClickedThreadPreview({ threadChannelId, messageId: graceMessageId }), + ).model + const withReply = updateWithShared( + updateWithShared( + opened, + Message.UpdatedThreadPanelMessages({ threadChannelId, messages: [reply] }), + ).model, + Message.UpdatedAttachments({ attachments: [imageAttachmentOf(3, reply.id)] }), + ).model + story( + updateWithShared, + given(withReply), + message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: reply.id, index: 0 }))), + model((current) => { + expect(Overlays.imageViewerOf(current.overlays)?.messageId).toBe(reply.id) + expect(current.threadPanel?.rows[0]?.attachments).toHaveLength(1) + }), + message(Message.UpdatedReactions({ reactions: [] })), + model((current) => expect(Overlays.imageViewerOf(current.overlays)?.messageId).toBe(reply.id)), + message(Message.ClickedCloseThread()), + message(Message.UpdatedReactions({ reactions: [] })), + model((current) => expect(Overlays.imageViewerOf(current.overlays)).toBeNull()), ) }) @@ -471,12 +672,14 @@ describe("image viewer", () => { updateWithShared, given(withImages()), message(overlays(Overlays.Message.PointerEnteredMessage({ messageId: graceMessageId }))), - message(overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 }))), + message( + overlays(Overlays.Message.ClickedAttachmentImage({ messageId: graceMessageId, index: 0 })), + ), message(Message.ChangedMessages({ order: [adaMessageId], upserts: [] })), - model((current) => expect(current.overlays.hoveredMessageId).toBeNull()), + model((current) => expect(Overlays.hoveredMessageIdOf(current.overlays)).toBeNull()), message(overlays(Overlays.Message.PointerEnteredMessage({ messageId: adaMessageId }))), message(Message.UpdatedReactions({ reactions: [] })), - model((current) => expect(current.overlays.hoveredMessageId).toBe(adaMessageId)), + model((current) => expect(Overlays.hoveredMessageIdOf(current.overlays)).toBe(adaMessageId)), ) }) }) diff --git a/apps/web-foldkit/src/page/chat/channel/subscription.test.ts b/apps/web-foldkit/src/page/chat/channel/subscription.test.ts index 34e97849d..c1999bf08 100644 --- a/apps/web-foldkit/src/page/chat/channel/subscription.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/subscription.test.ts @@ -2,7 +2,16 @@ import { ChannelId, TypingIndicatorId } from "@hazel/schema" import { Effect, Fiber, Schema, Stream } from "effect" import { describe, expect, test, vi } from "vitest" import { chatMessageOf, liveEmbeds } from "../../../test/chat-messages" -import { adaMemberId, adaMessageId, loadedModel, messages, shared, updateWithShared } from "./fixtures.test-support" +import { + adaMemberId, + adaMessageId, + loadedMessages, + loadedModel, + messages, + shared, + updateWithShared, +} from "./fixtures.test-support" +import { setTab } from "./page" import { Message, type Model } from "./model" import { indicatorIdsOf, subscriptions, type TypingCleanup, typingCleanupStream } from "./subscription" @@ -11,7 +20,9 @@ import { indicatorIdsOf, subscriptions, type TypingCleanup, typingCleanupStream // The Files tab's `ui/aria/interaction` and ProseMirror's view read `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 input = (model: Model) => ({ model, shared }) @@ -43,9 +54,11 @@ describe("channel subscription gates", () => { test("an AI reply in the window opens its actor connection; finished ones never do", () => { const live = chatMessageOf(10, { embeds: liveEmbeds, hasEmbeds: true }) - const model = updateWithShared(loadedModel(), Message.UpdatedMessages({ messages: [live, ...messages] })).model - expect(subscriptions.chatLiveReplies.modelToDependencies(input(model))).toEqual({ messageIds: [live.id] }) - const idle = updateWithShared(loadedModel(), Message.UpdatedMessages({ messages })).model + const model = updateWithShared(loadedModel(), loadedMessages([live, ...messages])).model + expect(subscriptions.chatLiveReplies.modelToDependencies(input(model))).toEqual({ + messageIds: [live.id], + }) + const idle = updateWithShared(loadedModel(), loadedMessages(messages)).model expect(subscriptions.chatLiveReplies.modelToDependencies(input(idle))).toEqual({ messageIds: [] }) }) @@ -53,23 +66,30 @@ describe("channel subscription gates", () => { const { keepAliveEquivalence } = subscriptions.chatLiveReplies const first = chatMessageOf(10, { embeds: liveEmbeds, hasEmbeds: true }) const second = chatMessageOf(11, { embeds: liveEmbeds, hasEmbeds: true }) - expect(keepAliveEquivalence({ messageIds: [first.id] }, { messageIds: [first.id, second.id] })).toBe(true) + expect(keepAliveEquivalence({ messageIds: [first.id] }, { messageIds: [first.id, second.id] })).toBe( + true, + ) expect(keepAliveEquivalence({ messageIds: [first.id] }, { messageIds: [] })).toBe(false) }) test("global typing is off on the Files tab", () => { - const files: Model = { ...loadedModel(), tab: "files" } + const files = setTab(loadedModel(), "files") expect(subscriptions.globalTyping.modelToDependencies(input(files))).toEqual({ isActive: false }) - expect(subscriptions.globalTyping.modelToDependencies(input(loadedModel()))).toEqual({ isActive: true }) + expect(subscriptions.globalTyping.modelToDependencies(input(loadedModel()))).toEqual({ + isActive: true, + }) }) test("an AI reply streaming in the open thread panel opens its actor connection", () => { const live = chatMessageOf(20, { channelId: threadChannelId, embeds: liveEmbeds, hasEmbeds: true }) - const opened: Model = { - ...loadedModel(), - overlays: { ...loadedModel().overlays, thread: { threadChannelId, messageId: adaMessageId } }, - } - const model = updateWithShared(opened, Message.UpdatedThreadPanelMessages({ messages: [live] })).model + const opened = updateWithShared( + loadedModel(), + Message.ClickedThreadPreview({ threadChannelId, messageId: adaMessageId }), + ).model + const model = updateWithShared( + opened, + Message.UpdatedThreadPanelMessages({ threadChannelId, messages: [live] }), + ).model expect(subscriptions.chatLiveReplies.modelToDependencies(input(model)).messageIds).toContain(live.id) }) }) @@ -84,10 +104,13 @@ describe("typing cleanup on leaving the page", () => { Effect.runPromise( Effect.gen(function* () { const deleted: Array<TypingIndicatorId> = [] - const fiber = yield* typingCleanupStream(own, () => latest, (id) => - Effect.sync(() => { - deleted.push(id) - }), + const fiber = yield* typingCleanupStream( + own, + () => latest, + (id) => + Effect.sync(() => { + deleted.push(id) + }), ).pipe(Stream.runDrain, Effect.forkChild) yield* Effect.yieldNow yield* Fiber.interrupt(fiber) @@ -97,7 +120,10 @@ describe("typing cleanup on leaving the page", () => { test("the drafts' indicators are the dependencies", () => { const model = loadedModel() - const typing: Model = { ...model, draft: { ...model.draft, typing: { ...model.draft.typing, indicatorId } } } + const typing: Model = { + ...model, + draft: { ...model.draft, typing: { ...model.draft.typing, indicatorId } }, + } expect(indicatorIdsOf(model)).toEqual([]) expect(subscriptions.typingCleanup.modelToDependencies(input(typing))).toEqual(own) }) diff --git a/apps/web-foldkit/src/page/chat/channel/subscription.ts b/apps/web-foldkit/src/page/chat/channel/subscription.ts index 5ffcc4b2d..ff801ffb8 100644 --- a/apps/web-foldkit/src/page/chat/channel/subscription.ts +++ b/apps/web-foldkit/src/page/chat/channel/subscription.ts @@ -70,7 +70,11 @@ const chat = Subscription.make<Input, Message, MessageWindows>()((entry) => ({ chatMessages: entry( { channelId: ChannelId, limit: Schema.Number, offset: Schema.Number }, { - modelToDependencies: ({ model }) => ({ channelId: model.channelId, limit: model.limit, offset: model.offset }), + modelToDependencies: ({ model }) => ({ + channelId: model.channelId, + limit: model.limit, + offset: model.offset, + }), // One live query per channel; paging moves its window instead of rebuilding it. keepAliveEquivalence: (previous, next) => previous.channelId === next.channelId, dependenciesToStream: ({ channelId, limit, offset }, readDependencies) => @@ -83,7 +87,11 @@ const chat = Subscription.make<Input, Message, MessageWindows>()((entry) => ({ chatMessagesWindow: entry( { channelId: ChannelId, limit: Schema.Number, offset: Schema.Number }, { - modelToDependencies: ({ model }) => ({ channelId: model.channelId, limit: model.limit, offset: model.offset }), + modelToDependencies: ({ model }) => ({ + channelId: model.channelId, + limit: model.limit, + offset: model.offset, + }), dependenciesToStream: (change) => Stream.fromEffect( Effect.gen(function* () { @@ -184,13 +192,13 @@ const chat = Subscription.make<Input, Message, MessageWindows>()((entry) => ({ { threadChannelId: Schema.NullOr(ChannelId) }, { modelToDependencies: ({ model }) => ({ - threadChannelId: model.overlays.thread?.threadChannelId ?? null, + threadChannelId: model.threadPanel?.threadChannelId ?? null, }), dependenciesToStream: ({ threadChannelId }) => threadChannelId === null - ? Stream.make(Message.UpdatedThreadPanelMessages({ messages: [] })) + ? Stream.empty : threadPanelStream(threadChannelId, (messages) => - Message.UpdatedThreadPanelMessages({ messages }), + Message.UpdatedThreadPanelMessages({ threadChannelId, messages }), ), }, ), @@ -229,7 +237,7 @@ const toChannelComposer = (message: Composer.Message) => const uploadEntry = (which: "channel" | "thread") => ({ modelToDependencies: ({ model }: Input) => { - const draft = which === "channel" ? model.draft : model.threadDraft + const draft = which === "channel" ? model.draft : (model.threadPanel?.draft ?? null) return { upload: draft?.currentUpload ?? null, channelId: draft?.channelId ?? null, @@ -256,13 +264,16 @@ const UploadDependencies = Schema.Struct({ }) type UploadDependencies = typeof UploadDependencies.Type -export const TypingCleanup = Schema.Struct({ channelId: ChannelId, indicatorIds: Schema.Array(TypingIndicatorId) }) +export const TypingCleanup = Schema.Struct({ + channelId: ChannelId, + indicatorIds: Schema.Array(TypingIndicatorId), +}) export type TypingCleanup = typeof TypingCleanup.Type const isSameCleanup = Schema.toEquivalence(TypingCleanup) /** The indicators the page's drafts hold (channel, and the open thread's). */ export const indicatorIdsOf = (model: Model): ReadonlyArray<TypingIndicatorId> => - [model.draft.typing.indicatorId, model.threadDraft?.typing.indicatorId ?? null].filter( + [model.draft.typing.indicatorId, model.threadPanel?.draft.typing.indicatorId ?? null].filter( (id): id is TypingIndicatorId => id !== null, ) @@ -283,7 +294,8 @@ export const typingCleanupStream = <R>( Effect.ensuring( Effect.suspend(() => { const latest = readLatest() - const isPageLeft = latest.channelId !== own.channelId || isSameCleanup(latest, own) + const isPageLeft = + latest.channelId !== own.channelId || isSameCleanup(latest, own) return isPageLeft ? Effect.forEach(own.indicatorIds, deleteIndicator, { discard: true }) : Effect.void @@ -304,7 +316,9 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en composerBots: entry( { organizationId: Schema.NullOr(Schema.String) }, { - modelToDependencies: ({ shared }) => ({ organizationId: shared.currentUser?.organizationId ?? null }), + modelToDependencies: ({ shared }) => ({ + organizationId: shared.currentUser?.organizationId ?? null, + }), dependenciesToStream: ({ organizationId }) => organizationId === null ? Stream.empty @@ -316,7 +330,9 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en composerCommands: entry( { organizationId: Schema.NullOr(Schema.String) }, { - modelToDependencies: ({ shared }) => ({ organizationId: shared.currentUser?.organizationId ?? null }), + modelToDependencies: ({ shared }) => ({ + organizationId: shared.currentUser?.organizationId ?? null, + }), dependenciesToStream: ({ organizationId }) => organizationId === null ? Stream.empty @@ -329,17 +345,22 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en { threadChannelId: Schema.NullOr(ChannelId), userId: Schema.NullOr(Schema.String) }, { modelToDependencies: ({ model }) => ({ - threadChannelId: model.overlays.thread?.threadChannelId ?? null, + threadChannelId: model.threadPanel?.threadChannelId ?? null, userId: model.currentUserId, }), dependenciesToStream: ({ threadChannelId, userId }) => threadChannelId === null || userId === null ? Stream.empty - : ownMembershipStream(threadChannelId, userId, (memberId) => Message.UpdatedThreadMember({ memberId })), + : ownMembershipStream(threadChannelId, userId, (memberId) => + Message.UpdatedThreadMember({ threadChannelId, memberId }), + ), }, ), typingCleanup: entry(TypingCleanup.fields, { - modelToDependencies: ({ model }) => ({ channelId: model.channelId, indicatorIds: indicatorIdsOf(model) }), + modelToDependencies: ({ model }) => ({ + channelId: model.channelId, + indicatorIds: indicatorIdsOf(model), + }), // Restarts on every change like the default; the variant only adds `readDependencies`. keepAliveEquivalence: isSameCleanup, dependenciesToStream: (own, readDependencies) => @@ -350,7 +371,9 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en globalTyping: entry( { isActive: Schema.Boolean }, { - modelToDependencies: ({ model }) => ({ isActive: model.tab === "messages" && isMemberOf(model) !== false }), + modelToDependencies: ({ model }) => ({ + isActive: model.tab._tag === "Messages" && isMemberOf(model) !== false, + }), dependenciesToStream: ({ isActive }) => isActive ? globalTypingStream((key) => Message.PressedGlobalKey({ key })) : Stream.empty, }, @@ -365,7 +388,7 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en })) const files = Subscription.lift(FilesSubscriptions.subscriptions)<Input, Message>({ - read: ({ model }) => Option.fromNullishOr(model.files), + read: ({ model }) => (model.tab._tag === "Files" ? Option.some(model.tab.files) : Option.none()), toParentMessage: (message) => Message.GotFilesMessage({ message }), }) diff --git a/apps/web-foldkit/src/page/chat/channel/view.ts b/apps/web-foldkit/src/page/chat/channel/view.ts index 18d62b748..4bbc754c2 100644 --- a/apps/web-foldkit/src/page/chat/channel/view.ts +++ b/apps/web-foldkit/src/page/chat/channel/view.ts @@ -3,65 +3,71 @@ import { contentStyles, rootStyles } from "~/components/ui/split-panel/split-pan import { isImageAttachment } from "../../../chat/attachments" import { imageViewerView } from "../../../chat/image-viewer" import { dateDividerView, messageRowView, type RowContext } from "../../../chat/message/row" +import type * as Draft from "../../../composer/draft" +import { draftView } from "../../../composer/draft-view" import * as MessageList from "../../../mount/message-list" -import { joinBannerView, typingIndicatorView, typingUsersOf } from "../banners" +import { mobileMenuButton } from "../../../shell/mobile" +import { joinBannerView, typingIndicatorView } from "../banners" +import { attachmentInfoFrom, composerAreaView, replyPreviewOf } from "../composer-view" import { authorIdentity } from "../derive" +import * as FilesPage from "../files/page" import * as FilesView from "../files/view" -import { mobileMenuButton } from "../../../shell/mobile" import { chatHeaderView } from "../header" -import { pinnedPopoverView } from "../pinned" import { messageToolbarOverlay, reactionModalView, trackHoverAttribute } from "../overlay-views" -import { attachmentInfoFrom, composerAreaView, replyPreviewOf } from "../composer-view" -import type * as Draft from "../../../composer/draft" -import { draftView } from "../../../composer/draft-view" import * as Overlays from "../overlays" -import { deriveContextOf, isMemberOf, Message, type Model } from "./page" +import { pinnedPopoverView } from "../pinned" import { idleRowContext, rowContextFor } from "../row-context" -import type { DisplayRow } from "../rows" +import type { DisplayRow, MessageRow } from "../rows" import { chatTabBarView } from "../tab-bar" import { threadPanelView } from "../thread-panel" +import { deriveContextOf, isMemberOf, Message, type Model, routeTabOf, type ThreadPanel } from "./page" /** The channel route's content: header, tab bar, message list and composer (desktop). */ -/** One memo slot per rendered row; slots of rows that scroll away are dropped. */ -const rowSlots = new Map<string, ReturnType<typeof createLazy>>() -const renderedThisFrame = new Set<string>() -const MAX_IDLE_SLOTS = 200 +type H = HtmlBuilder<Message> + +// The page's mappers, built once, so the lazy views that take them keep their memo. -const rowView = <M>( +const toSelf = (message: Message) => message +const toListMessage = (message: MessageList.Message) => Message.GotListMessage({ message }) +const toDraftMessage = (message: Draft.Message) => Message.GotDraftMessage({ message }) +const toThreadDraftMessage = (message: Draft.Message) => Message.GotThreadDraftMessage({ message }) +const toFilesMessage = (message: FilesPage.Message) => Message.GotFilesMessage({ message }) +const toOverlay = (message: Overlays.Message) => Message.GotOverlaysMessage({ message }) +const toPinnedMessage = (message: Parameters<typeof Overlays.Message.GotPinnedMessage>[0]["message"]) => + toOverlay(Overlays.Message.GotPinnedMessage({ message })) + +const rowView = ( row: DisplayRow, isStuck: boolean, isHovered: boolean, - context: RowContext<M>, - h: HtmlBuilder<M>, + context: RowContext<Message>, + h: H, ): Html => row._tag === "DateHeader" ? dateDividerView(h, row.label, isStuck) : messageRowView(h, row, context, isHovered) -const memoRow = <M>( - h: HtmlBuilder<M>, +// One memo slot per row object, a pure view memo: a row derived again gets a fresh slot and the old +// one goes with the old row (rows keep their identity while nothing they show changes). +const rowSlots = new WeakMap<DisplayRow, ReturnType<typeof createLazy>>() + +const memoRow = ( + h: H, row: DisplayRow, isStuck: boolean, isHovered: boolean, - context: RowContext<M>, + context: RowContext<Message>, ): Html => { - let slot = rowSlots.get(row.key) - // createKeyedLazy never evicts and message rows are unbounded, so slots live here and pruneRowSlots drops them. + let slot = rowSlots.get(row) + // createKeyedLazy never evicts and message rows are unbounded, so slots live with their row. // oxlint-disable-next-line foldkit/lazy-view-stable-references - if (slot === undefined) rowSlots.set(row.key, (slot = createLazy())) - renderedThisFrame.add(row.key) + if (slot === undefined) rowSlots.set(row, (slot = createLazy())) return slot(rowView, [row, isStuck, isHovered, context, h]) ?? h.div([]) } -const pruneRowSlots = () => { - if (rowSlots.size > renderedThisFrame.size + MAX_IDLE_SLOTS) - for (const key of rowSlots.keys()) if (!renderedThisFrame.has(key)) rowSlots.delete(key) - renderedThisFrame.clear() -} - /** `MessageList`'s empty state. */ -const emptyStateView = <M>(h: HtmlBuilder<M>): Html => +const emptyStateView = (h: H): Html => h.div( [h.Class("flex size-full flex-col items-center justify-center p-4 sm:p-8")], [ @@ -109,22 +115,17 @@ const stableInput = (isEqual: (a: Model, b: Model) => boolean) => { const listInputOf = stableInput(isListInputEqual) const lazyList = createLazy() -const messageListView = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: (message: Message) => M, - nowMs: number, -): Html => lazyList(listView, [listInputOf(model), toParentMessage, nowMs, h]) ?? h.empty +/** The presence clock only matters to an open profile popover; otherwise it is left out of the memo. */ +const popoverClockOf = (model: Model, nowMs: number) => (model.overlays.open._tag === "Popover" ? nowMs : 0) -const listView = <M>( - model: Model, - toParentMessage: (message: Message) => M, - nowMs: number, - h: HtmlBuilder<M>, -): Html => { +const messageListView = (h: H, model: Model, nowMs: number): Html => + lazyList(listView, [listInputOf(model), popoverClockOf(model, nowMs), h]) ?? h.empty + +const listView = (model: Model, nowMs: number, h: H): Html => { if (!model.hasLoadedMessages) return h.div([]) if (model.messages.length === 0) return emptyStateView(h) - const idle = idleRowContext(h, model, toParentMessage) + const idle = idleRowContext(h, model) + const hovered = Overlays.hoveredMessageIdOf(model.overlays) const list = MessageList.view(h, model.list, { items: model.rows, itemToKey: (row) => row.key, @@ -133,13 +134,12 @@ const listView = <M>( h, row, isStuck, - row.key === model.overlays.hoveredMessageId, - row._tag === "MessageRow" ? rowContextFor(h, model, row, toParentMessage, idle, nowMs) : idle, + row.key === hovered, + row._tag === "MessageRow" ? rowContextFor(h, model, row, idle, nowMs) : idle, ), isStickyHeader: (row) => row._tag === "DateHeader", - toParentMessage: (message) => toParentMessage(Message.GotListMessage({ message })), + toParentMessage: toListMessage, }) - pruneRowSlots() // Keyed: the not-yet-loaded placeholder is also a div, and a reused element never runs OnMount. return h.keyed("div")( "message-list-container", @@ -148,7 +148,7 @@ const listView = <M>( "isolate flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-2 transition-opacity duration-200", ), h.Attribute("style", "overflow-anchor: auto; scroll-behavior: auto; opacity: 1;"), - trackHoverAttribute(h, toParentMessage), + trackHoverAttribute(h), ], [ // The legacy hover highlight (injected `#message-<id>` CSS) is a class on the hovered row: @@ -158,17 +158,19 @@ const listView = <M>( ) } +/** The row an image viewer opened from: a channel row, or a reply in the open thread. */ +const viewerRowOf = (model: Model, messageId: string): MessageRow | undefined => { + const row = model.rows.find((candidate) => candidate.key === messageId) + if (row !== undefined) return row._tag === "MessageRow" ? row : undefined + return model.threadPanel?.rows.find((candidate) => candidate.key === messageId) +} + /** `ImageViewerModal` for the message whose attachment was clicked. */ -const imageViewerOverlay = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: (message: Message) => M, -): Html => { - const viewer = model.overlays.imageViewer +const imageViewerOverlay = (h: H, model: Model): Html => { + const viewer = Overlays.imageViewerOf(model.overlays) if (viewer === null) return h.empty - const row = model.rows.find((candidate) => candidate.key === viewer.messageId) - if (row === undefined || row._tag !== "MessageRow") return h.empty - const overlays = (message: Overlays.Message) => toParentMessage(Message.GotOverlaysMessage({ message })) + const row = viewerRowOf(model, viewer.messageId) + if (row === undefined) return h.empty return imageViewerView(h, { images: viewer.urlImages === null @@ -188,90 +190,60 @@ const imageViewerOverlay = <M>( } : null, createdAtMs: row.message.createdAtMs, - toClose: () => overlays(Overlays.Message.ClosedImageViewer()), - toSelect: (index) => overlays(Overlays.Message.SelectedViewerImage({ index })), + toClose: () => toOverlay(Overlays.Message.ClosedImageViewer()), + toSelect: (index) => toOverlay(Overlays.Message.SelectedViewerImage({ index })), toAction: (action, image) => - overlays(Overlays.Message.ClickedViewerAction({ action, url: image.url, fileName: image.fileName })), + toOverlay( + Overlays.Message.ClickedViewerAction({ action, url: image.url, fileName: image.fileName }), + ), }) } /** `$id/index.tsx`: the join banner for non-members, else the list and the composer. */ -const messagesOutlet = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: (message: Message) => M, - nowMs: number, -) => { - const isMember = isMemberOf(model) - if (isMember === false) return [joinBannerView(h, model.channel)] - const typingUsers = typingUsersOf( - model.typing, - model.members, - model.lookups.users, - model.currentUserId, - model.typingNowMs, - ) +const messagesOutlet = (h: H, model: Model, nowMs: number) => { + if (isMemberOf(model) === false) + return [ + joinBannerView(h, model.channel, { + isJoining: model.isJoining, + onJoin: Message.ClickedJoinChannel(), + }), + ] // Passed to the lazy composer as strings, so an unchanged reply keeps its memo. const reply = replyPreviewOf(model, model.draft) return [ - h.div( - [h.Class("flex min-h-0 flex-1 flex-col overflow-hidden")], - [messageListView(h, model, toParentMessage, nowMs)], - ), + h.div([h.Class("flex min-h-0 flex-1 flex-col overflow-hidden")], [messageListView(h, model, nowMs)]), lazyComposer(composerView, [ - typingUsers.length === 0 ? null : typingUsers.map((user) => user.firstName).join(), + model.typingNames, model.draft, reply?.authorName ?? null, reply?.firstLine ?? null, model.lookups.attachments, - toParentMessage, h, ]) ?? h.empty, - imageViewerOverlay(h, model, toParentMessage), - lazyToolbar(messageToolbarOverlay, [h, toolbarInputOf(model), toParentMessage]) ?? h.empty, - reactionModalView(h, model, toParentMessage), + imageViewerOverlay(h, model), + lazyToolbar(messageToolbarOverlay, [h, toolbarInputOf(model)]) ?? h.empty, + reactionModalView(h, model), ] } -const composerView = <M>( - typingKey: string | null, +const composerView = ( + typingNames: ReadonlyArray<string>, draft: Draft.Model, replyAuthorName: string | null, replyFirstLine: string | null, attachments: Model["lookups"]["attachments"], - toParentMessage: (message: Message) => M, - h: HtmlBuilder<M>, + h: H, ) => - composerAreaView( - h, - typingKey === null - ? null - : typingIndicatorView( - h, - typingKey.split(",").map((firstName) => ({ firstName })), - ), - draft, - { - toMessage: toDraftMessage(toParentMessage), - replyPreview: - replyAuthorName === null || replyFirstLine === null - ? null - : { authorName: replyAuthorName, firstLine: replyFirstLine }, - attachmentInfo: attachmentInfoFrom(attachments), - }, - ) - -/** One mapping function per parent mapping, so the composer's lazy arguments stay equal. */ -const draftMappers = new WeakMap<object, (message: Draft.Message) => unknown>() -const toDraftMessage = <M>(toParentMessage: (message: Message) => M): ((message: Draft.Message) => M) => { - const cached = draftMappers.get(toParentMessage) - if (cached) return cached as (message: Draft.Message) => M - const mapper = (message: Draft.Message) => toParentMessage(Message.GotDraftMessage({ message })) - draftMappers.set(toParentMessage, mapper) - return mapper -} + composerAreaView(h, typingIndicatorView(h, typingNames), draft, { + toMessage: toDraftMessage, + replyPreview: + replyAuthorName === null || replyFirstLine === null + ? null + : { authorName: replyAuthorName, firstLine: replyFirstLine }, + attachmentInfo: attachmentInfoFrom(attachments), + }) -const headerView = <M>( +const headerView = ( channel: Model["channel"], parentChannel: Model["parentChannel"], orgSlug: Model["orgSlug"], @@ -281,14 +253,12 @@ const headerView = <M>( pinnedPopover: Model["overlays"]["pinned"], pins: Model["pinned"], isMobile: boolean, - toParentMessage: (message: Message) => M, - h: HtmlBuilder<M>, + h: H, ): Html => { - const model = { lookups, members, currentUserId } - const users = new Map(model.lookups.users.map((user) => [user.id, user])) - const botNames = new Map(model.lookups.bots.map((bot) => [bot.userId, bot.name])) - const others = (model.members ?? []).filter((member) => member.userId !== model.currentUserId) - const currentMember = (model.members ?? []).find((member) => member.userId === model.currentUserId) + const users = new Map(lookups.users.map((user) => [user.id, user])) + const botNames = new Map(lookups.bots.map((bot) => [bot.userId, bot.name])) + const others = (members ?? []).filter((member) => member.userId !== currentUserId) + const currentMember = (members ?? []).find((member) => member.userId === currentUserId) return chatHeaderView(h, { channel, parentChannel, @@ -303,15 +273,11 @@ const headerView = <M>( mobileMenu: (className) => isMobile ? mobileMenuButton(h, { - onPress: h.OnClick(toParentMessage(Message.ClickedMobileMenu())), + onPress: h.OnClick(Message.ClickedMobileMenu()), ...(className === undefined ? {} : { className }), }) : h.empty, - pinnedTrigger: pinnedPopoverView(h, pinnedPopover, pins, (message) => - toParentMessage( - Message.GotOverlaysMessage({ message: Overlays.Message.GotPinnedMessage({ message }) }), - ), - ), + pinnedTrigger: pinnedPopoverView(h, pinnedPopover, pins, toPinnedMessage), }) } @@ -330,19 +296,20 @@ const lazyToolbar = createLazy() const lazyHeader = createLazy() const lazyTabBar = createLazy() const lazyComposer = createLazy() +const lazyThreadPanel = createLazy() + +export interface ViewOptions { + readonly isMobile?: boolean + /** `Shared.nowMs`, the presence clock the profile popover and the Files dates read. */ + readonly nowMs?: number +} /** `SplitPanelRoot` > `SplitPanelContent` with the channel's current tab inside. */ -export const view = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: (message: Message) => M, - isMobile = false, - /** `Shared.nowMs`, the presence clock the profile popover reads. */ - nowMs = 0, -): Html => +export const view = (h: H, model: Model, options: ViewOptions = {}): Html => { + const nowMs = options.nowMs ?? 0 // Keyed by channel like React's `key={id}`: Mounts start on insert only, so a patched-in-place // page would keep the previous channel's list observer and never measure the new viewport. - h.keyed("div")( + return h.keyed("div")( model.channelId, [h.Class(rootStyles({ className: "h-[calc(100dvh-4rem)] md:h-dvh" }))], [ @@ -358,51 +325,61 @@ export const view = <M>( model.currentUserId, model.overlays.pinned, model.pinned, - isMobile, - toParentMessage, + options.isMobile ?? false, h, ]) ?? h.empty, - lazyTabBar(chatTabBarView, [model.tab, toParentMessage, h]) ?? h.empty, - ...(model.tab === "messages" || model.files === null - ? messagesOutlet(h, model, toParentMessage, nowMs) - : [ - FilesView.view(h, model.files, (message) => - toParentMessage(Message.GotFilesMessage({ message })), - ), - ]), + lazyTabBar(chatTabBarView, [routeTabOf(model.tab), toSelf, h]) ?? h.empty, + ...(model.tab._tag === "Files" + ? [ + FilesView.view(h, model.tab.files, toFilesMessage, nowMs), + FilesView.imageViewerOverlay(h, model.tab.files, toFilesMessage), + ] + : messagesOutlet(h, model, nowMs)), ], ), - threadPanelOverlay(h, model, toParentMessage), + model.threadPanel === null + ? h.empty + : (lazyThreadPanel(threadPanelOverlay, [ + model.threadPanel, + model.lookups, + model.messages, + model.currentUserId, + idleRowContext(h, model), + h, + ]) ?? h.empty), ], ) +} /** The `SplitPanel` beside the channel while a thread is open. */ -const threadPanelOverlay = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: (message: Message) => M, +const threadPanelOverlay = ( + panel: ThreadPanel, + lookups: Model["lookups"], + messages: Model["messages"], + currentUserId: Model["currentUserId"], + rowContext: RowContext<Message>, + h: H, ): Html => { - const thread = model.overlays.thread - if (thread === null) return h.empty - const name = model.lookups.threadChannels.find((channel) => channel.id === thread.threadChannelId)?.name + const context = deriveContextOf({ lookups, currentUserId, unfurls: {}, liveStates: {} }) + const name = lookups.threadChannels.find((channel) => channel.id === panel.threadChannelId)?.name return threadPanelView(h, { threadName: name || "Thread", - original: model.messages.find((message) => message.id === thread.messageId) ?? null, - messages: model.threadMessages, - context: deriveContextOf(model), - rowContext: idleRowContext(h, model, toParentMessage), - onClose: toParentMessage(Message.GotOverlaysMessage({ message: Overlays.Message.ClosedThread() })), - onGenerateName: toParentMessage(Message.ClickedGenerateThreadName()), - onRename: toParentMessage(Message.ClickedRenameThread()), - isGeneratingName: model.isGeneratingThreadName, - isCreating: model.pendingThreadChannelId === thread.threadChannelId, - composer: - model.threadDraft === null - ? h.empty - : draftView(h, model.threadDraft, { - toMessage: (message) => toParentMessage(Message.GotThreadDraftMessage({ message })), - replyPreview: replyPreviewOf(model, model.threadDraft), - attachmentInfo: attachmentInfoFrom(model.lookups.attachments), - }), + original: messages.find((message) => message.id === panel.messageId) ?? null, + rows: panel.rows, + context, + rowContext, + onClose: Message.ClickedCloseThread(), + onGenerateName: Message.ClickedGenerateThreadName(), + onRename: Message.ClickedRenameThread(), + isGeneratingName: panel.isNaming, + isCreating: panel.creation === "Creating", + composer: draftView(h, panel.draft, { + toMessage: toThreadDraftMessage, + replyPreview: replyPreviewOf( + { lookups, currentUserId, messages, threadPanel: panel }, + panel.draft, + ), + attachmentInfo: attachmentInfoFrom(lookups.attachments), + }), }) } diff --git a/apps/web-foldkit/src/page/chat/channel/window.ts b/apps/web-foldkit/src/page/chat/channel/window.ts new file mode 100644 index 000000000..28fca5b63 --- /dev/null +++ b/apps/web-foldkit/src/page/chat/channel/window.ts @@ -0,0 +1,46 @@ +import { modifyFields } from "foldkit/struct" +import * as MessageList from "../../../mount/message-list" +import { PAGE_SIZE } from "../queries" +import type { Model, PageReturn } from "./model" + +/** The loaded message window: how far it reaches back, and paging at its edges (read and write path). */ + +/** The most messages the window holds (S2 condition 1); past it, older pages slide the window. */ +export const MAX_WINDOW = 10 * PAGE_SIZE + +/** Only a window that reaches the newest message follows the end. */ +const withWindowFollow = (model: Model): Model => { + const list = MessageList.setCanFollowEnd(model.list, model.offset === 0) + return list === model.list ? model : modifyFields(model, { list: () => list }) +} + +/** + * Grows the window by a page near the oldest message, then slides it; near the end it slides back. + * Only while the list is on screen: a hidden list's offsets describe no reader. + */ +export const loadWhenNearEdge = (result: PageReturn): PageReturn => { + const { model } = result + if (model.tab._tag !== "Messages") return result + const isPageFull = model.messages.length >= model.limit + if (isPageFull && MessageList.isNearStart(model.list)) { + const next = + model.limit < MAX_WINDOW + ? modifyFields(model, { limit: (limit) => limit + PAGE_SIZE }) + : modifyFields(model, { offset: (offset) => offset + PAGE_SIZE }) + return { ...result, model: withWindowFollow(next) } + } + if (model.offset > 0 && MessageList.isNearEnd(model.list)) + return { + ...result, + model: withWindowFollow( + modifyFields(model, { offset: (offset) => Math.max(0, offset - PAGE_SIZE) }), + ), + } + return result +} + +/** Back to the newest page (after sending from a scrolled-back window). */ +export const resetWindow = (model: Model): Model => + model.offset === 0 + ? model + : withWindowFollow(modifyFields(model, { offset: () => 0, limit: () => PAGE_SIZE })) diff --git a/apps/web-foldkit/src/page/chat/channel/write.ts b/apps/web-foldkit/src/page/chat/channel/write.ts index 27d52a3cf..653feb90d 100644 --- a/apps/web-foldkit/src/page/chat/channel/write.ts +++ b/apps/web-foldkit/src/page/chat/channel/write.ts @@ -1,19 +1,22 @@ import { type ChannelId, type MessageId, PinnedMessageId } from "@hazel/schema" import { Match, Option, Schema } from "effect" import { Command } from "foldkit" +import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import * as Composer from "../../../composer/composer" import * as Draft from "../../../composer/draft" import * as DraftUpdate from "../../../composer/draft-update" import * as EditorCommands from "../../../composer/editor-commands" import * as Typing from "../../../composer/typing" +import { errorToast, errorToastOf, successToast } from "../../../data/actions" import * as MessageList from "../../../mount/message-list" import type { ToastRequest } from "../../../overlay/toasts" import type { HazelRpc } from "../../../rpc" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, successToast } from "../../../data/actions" import { ActionMessage, + type CopiedContent, CopyText, CreateThread, DownloadImage, @@ -26,8 +29,8 @@ import { } from "../message-actions" import * as Overlays from "../overlays" import type { ChatMessage } from "../rows" -import { Message, type Model, type PageReturn } from "./model" -import { resetWindow } from "./page" +import { Message, type Model, type PageReturn, type ThreadCreation, type ThreadPanel } from "./model" +import { resetWindow } from "./window" /** The channel page's write path: `ChatProvider`'s actions and the two drafts (channel and thread). */ @@ -35,17 +38,39 @@ export type Which = "channel" | "thread" const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) +/** + * Runs a second step on the first's Model, keeping both steps' Commands and the later OutMessage + * (`Update.combine` for page steps, which can each raise one). + */ +export const andThen = (first: PageReturn, second: (model: Model) => PageReturn): PageReturn => { + const next = second(first.model) + return { + model: next.model, + commands: [...(first.commands ?? []), ...(next.commands ?? [])], + ...((next.outMessage ?? first.outMessage) === undefined + ? {} + : { outMessage: next.outMessage ?? first.outMessage }), + } +} + const wrapDraft = (which: Which) => (message: Draft.Message) => which === "channel" ? Message.GotDraftMessage({ message }) : Message.GotThreadDraftMessage({ message }) const wrapAction = (message: ActionMessage) => Message.GotActionMessage({ message }) -const actionCommands = (commands: ReadonlyArray<Command.Command<ActionMessage, never, HazelRpc>>) => +export const actionCommands = (commands: ReadonlyArray<Command.Command<ActionMessage, never, HazelRpc>>) => Command.mapMessages(commands, wrapAction) /** The draft that receives global typing: the thread's while one is open (legacy `disableGlobalKeyboardFocus`). */ -export const focusedDraft = (model: Model): Which => - model.overlays.thread !== null && model.threadDraft !== null ? "thread" : "channel" +export const focusedDraft = (model: Model): Which => (model.threadPanel !== null ? "thread" : "channel") + +const draftOf = (model: Model, which: Which): Draft.Model | null => + which === "channel" ? model.draft : (model.threadPanel?.draft ?? null) + +const withPanel = (model: Model, change: (panel: ThreadPanel) => ThreadPanel): Model => + model.threadPanel === null + ? model + : modifyFields(model, { threadPanel: (panel) => (panel === null ? null : change(panel)) }) const lastOwn = (messages: ReadonlyArray<ChatMessage>, currentUserId: string | null) => Option.fromNullishOr(messages.find((message) => message.authorId === currentUserId)).pipe( @@ -60,17 +85,20 @@ export const contextFor = (model: Model, which: Which, shared: Shared | null): D memberId: which === "channel" ? (model.members?.find((member) => member.userId === model.currentUserId)?.id ?? null) - : model.threadMemberId, + : (model.threadPanel?.memberId ?? null), // `messages` is newest first; the thread panel's are oldest first. lastOwnMessage: which === "channel" ? lastOwn(model.messages, model.currentUserId) - : lastOwn([...model.threadMessages].reverse(), model.currentUserId), + : lastOwn([...(model.threadPanel?.messages ?? [])].reverse(), model.currentUserId), }) /** Folds a draft result back into the page: commands wrapped, toasts raised, sends scroll to the end. */ export const liftDraft = (model: Model, which: Which, result: DraftUpdate.Return): PageReturn => { - const next: Model = which === "channel" ? { ...model, draft: result.model } : { ...model, threadDraft: result.model } + const next = + which === "channel" + ? modifyFields(model, { draft: () => result.model }) + : withPanel(model, (panel) => modifyFields(panel, { draft: () => result.model })) const commands = Command.mapMessages(result.commands ?? [], wrapDraft(which)) const out = result.outMessage if (out === undefined) return { model: next, commands } @@ -82,10 +110,12 @@ export const liftDraft = (model: Model, which: Which, result: DraftUpdate.Return const present = resetWindow(next) const scrolled = MessageList.scrollToEnd(present.list) return { - model: { ...present, list: scrolled.model }, + model: modifyFields(present, { list: () => scrolled.model }), commands: [ ...commands, - ...Command.mapMessages(scrolled.commands ?? [], (message) => Message.GotListMessage({ message })), + ...Command.mapMessages(scrolled.commands ?? [], (message) => + Message.GotListMessage({ message }), + ), ], } }, @@ -104,32 +134,47 @@ const SHARED_COMPOSER_DATA: ReadonlyArray<string> = [ const isSharedComposerData = (message: Draft.Message) => message._tag === "GotComposerMessage" && SHARED_COMPOSER_DATA.includes(message.message._tag) -export const updateDraft = (model: Model, which: Which, message: Draft.Message, shared: Shared | null): PageReturn => { - const draft = which === "channel" ? model.draft : model.threadDraft - if (draft === null) return { model } - const result = liftDraft(model, which, DraftUpdate.update(draft, message, contextFor(model, which, shared))) - const thread = result.model.threadDraft - if (which === "thread" || thread === null || !isSharedComposerData(message)) return result - const threadResult = liftDraft( - result.model, - "thread", - DraftUpdate.update(thread, message, contextFor(result.model, "thread", shared)), - ) - return { ...result, model: threadResult.model, commands: [...(result.commands ?? []), ...(threadResult.commands ?? [])] } +const updateOneDraft = ( + model: Model, + which: Which, + message: Draft.Message, + shared: Shared | null, +): PageReturn => { + const draft = draftOf(model, which) + return draft === null + ? { model } + : liftDraft(model, which, DraftUpdate.update(draft, message, contextFor(model, which, shared))) } +export const updateDraft = ( + model: Model, + which: Which, + message: Draft.Message, + shared: Shared | null, +): PageReturn => + which === "channel" && isSharedComposerData(message) + ? andThen(updateOneDraft(model, "channel", message, shared), (next) => + updateOneDraft(next, "thread", message, shared), + ) + : updateOneDraft(model, which, message, shared) + /** Presence and custom emojis arrive as page lookups; the composers get their copy. */ export const forwardComposerData = (model: Model, message: Composer.Message): PageReturn => updateDraft(model, "channel", Draft.Message.GotComposerMessage({ message }), null) -export const toComposerPresence = (presence: ReadonlyArray<{ readonly userId: string; readonly status: string }>) => - presence.map((row) => ({ userId: row.userId, status: isPresenceStatus(row.status) ? row.status : ("offline" as const) })) +export const toComposerPresence = ( + presence: ReadonlyArray<{ readonly userId: string; readonly status: string }>, +) => + presence.map((row) => ({ + userId: row.userId, + status: isPresenceStatus(row.status) ? row.status : ("offline" as const), + })) const isPresenceStatus = Schema.is(Composer.PresenceStatus) const findMessage = (model: Model, messageId: MessageId) => model.messages.find((message) => message.id === messageId) ?? - model.threadMessages.find((message) => message.id === messageId) + model.threadPanel?.messages.find((message) => message.id === messageId) /** `handleReaction`: toggle the reaction (`toggleReactionAction`). */ export const react = (model: Model, messageId: MessageId, emoji: string): PageReturn => { @@ -137,30 +182,89 @@ export const react = (model: Model, messageId: MessageId, emoji: string): PageRe const channelId = findMessage(model, messageId)?.channelId ?? model.channelId return { model, - commands: actionCommands([ToggleReaction({ messageId, channelId, emoji, userId: model.currentUserId })]), + commands: actionCommands([ + ToggleReaction({ messageId, channelId, emoji, userId: model.currentUserId }), + ]), } } -/** The thread panel opens on a thread; its draft and membership start fresh. */ -const openThread = (model: Model, threadChannelId: ChannelId, messageId: MessageId): PageReturn => - syncThreadDraft({ ...model, overlays: Overlays.openThread(model.overlays, threadChannelId, messageId) }) +// THREAD PANEL export const newThreadDraft = (model: Model, threadChannelId: ChannelId): Draft.Model => { const draft = Draft.init(threadChannelId, `thread-composer-${threadChannelId}`, "Reply in thread...") const { members, presence, mentionableBots, botCommands, customEmojis } = model.draft.composer - return { ...draft, composer: { ...draft.composer, members, presence, mentionableBots, botCommands, customEmojis } } + return { + ...draft, + composer: { ...draft.composer, members, presence, mentionableBots, botCommands, customEmojis }, + } } +/** `useTyping` deletes the indicator when the panel's composer unmounts. */ +const stopThreadTyping = (model: Model): ReadonlyArray<Command.Command<Message, never, HazelRpc>> => + model.threadPanel === null + ? [] + : Command.mapMessages(Typing.stop(model.threadPanel.draft.typing).commands ?? [], (message) => + Message.GotThreadDraftMessage({ message: Draft.Message.GotTypingMessage({ message }) }), + ) + +/** The thread panel opens on a thread: a new panel (a new `ChatProvider`), with its own draft. */ +export const openThread = ( + model: Model, + threadChannelId: ChannelId, + messageId: MessageId, + creation: ThreadCreation, +): PageReturn => + model.threadPanel?.threadChannelId === threadChannelId && model.threadPanel.messageId === messageId + ? { model } + : { + model: modifyFields(model, { + threadPanel: () => ({ + threadChannelId, + messageId, + draft: newThreadDraft(model, threadChannelId), + memberId: null, + creation, + isNaming: false, + messages: [], + rows: [], + }), + }), + commands: stopThreadTyping(model), + } + +export const closeThread = (model: Model): PageReturn => + model.threadPanel === null + ? { model } + : { model: modifyFields(model, { threadPanel: () => null }), commands: stopThreadTyping(model) } + /** `createThread`: open an existing thread, or create one with a fresh id. */ const createThread = (model: Model, messageId: MessageId): PageReturn => { if (model.channel?.type === "thread") return { model, outMessage: toast(errorToast("Cannot create threads within threads")) } const message = findMessage(model, messageId) - if (message?.threadChannelId) return openThread(model, message.threadChannelId, messageId) + if (message?.threadChannelId) return openThread(model, message.threadChannelId, messageId, "Created") if (model.currentUserId === null) return { model } return { model, commands: actionCommands([GenerateThreadChannelId({ messageId })]) } } +/** The thread panel header's "Generate thread name". */ +export const generateThreadName = (model: Model): PageReturn => { + const panel = model.threadPanel + if (panel === null || panel.isNaming) return { model } + return { + model: withPanel(model, (current) => modifyFields(current, { isNaming: () => true })), + commands: actionCommands([GenerateThreadName({ channelId: panel.threadChannelId })]), + } +} + +/** A naming result: the panel stops waiting only if it still shows that thread. */ +const namedThread = (model: Model, threadChannelId: ChannelId): Model => + model.threadPanel?.threadChannelId === threadChannelId + ? withPanel(model, (panel) => modifyFields(panel, { isNaming: () => false })) + : model + +// ACTIONS + const pinnedIdOf = (model: Model, messageId: MessageId) => Option.fromNullishOr(model.pinned.find((pin) => pin.messageId === messageId)?.pinnedId).pipe( Option.flatMap(Schema.decodeUnknownOption(PinnedMessageId)), @@ -169,22 +273,41 @@ const pinnedIdOf = (model: Model, messageId: MessageId) => /** `handlePin`: unpin by the pin's id, else pin. */ const togglePin = (model: Model, messageId: MessageId): PageReturn => { const pinnedId = pinnedIdOf(model, messageId) - if (Option.isSome(pinnedId)) return { model, commands: actionCommands([UnpinMessage({ pinnedMessageId: pinnedId.value })]) } + if (Option.isSome(pinnedId)) + return { model, commands: actionCommands([UnpinMessage({ pinnedMessageId: pinnedId.value })]) } if (model.currentUserId === null) return { model } return { model, - commands: actionCommands([PinMessage({ messageId, channelId: model.channelId, userId: model.currentUserId })]), + commands: actionCommands([ + PinMessage({ messageId, channelId: model.channelId, userId: model.currentUserId }), + ]), } } -const copy = (model: Model, text: string, description: string): PageReturn => ({ +const copy = (model: Model, text: string, content: CopiedContent): PageReturn => ({ model, - commands: actionCommands([CopyText({ text })]), - outMessage: toast(successToast("Copied!", description)), + commands: actionCommands([CopyText({ text, content })]), }) -/** `ImageViewerModal`'s toolbar: legacy toasts at once, without waiting for the effect. */ -const viewerAction = (model: Model, action: Overlays.ViewerAction, url: string, fileName: string): PageReturn => +const copiedToast = (content: CopiedContent): ToastRequest => + Match.value(content).pipe( + Match.when("MessageText", () => + successToast("Copied!", "Message content has been copied to your clipboard."), + ), + Match.when("MessageId", () => + successToast("Copied!", "Message ID has been copied to your clipboard."), + ), + Match.when("ImageUrl", () => successToast("URL copied", "Image URL has been copied to clipboard.")), + Match.exhaustive, + ) + +/** `ImageViewerModal`'s toolbar (the channel's viewer and the Files tab's). */ +export const viewerAction = ( + model: Model, + action: Overlays.ViewerAction, + url: string, + fileName: string, +): PageReturn => Match.value(action).pipe( Match.withReturnType<PageReturn>(), Match.when("download", () => ({ @@ -192,41 +315,57 @@ const viewerAction = (model: Model, action: Overlays.ViewerAction, url: string, commands: actionCommands([DownloadImage({ url, fileName })]), outMessage: toast(successToast("Image downloaded", "Your image has been downloaded.")), })), - Match.when("copyUrl", () => ({ + Match.when("copyUrl", () => copy(model, url, "ImageUrl")), + Match.when("openInBrowser", () => ({ model, commands: actionCommands([OpenUrl({ url })]) })), + Match.exhaustive, + ) + +/** The context menu's "Add Reaction": the emoji picker modal. */ +const openReactionModal = (model: Model, messageId: MessageId): PageReturn => { + const opened = Overlays.openReactionModal(model.overlays, messageId) + return { + model: modifyFields(model, { overlays: () => opened.model }), + commands: Command.mapMessages(opened.commands ?? [], (message) => + Message.GotOverlaysMessage({ message }), + ), + } +} + +const messageAction = (model: Model, messageId: MessageId, action: Overlays.MessageAction): PageReturn => { + const message = findMessage(model, messageId) + return Match.value(action).pipe( + Match.withReturnType<PageReturn>(), + Match.when("reply", () => ({ + model: modifyFields(model, { draft: (draft) => DraftUpdate.setReply(draft, messageId) }), + })), + Match.when("edit", () => + message === undefined + ? { model } + : liftDraft( + model, + "channel", + DraftUpdate.startEditing(model.draft, messageId, message.content), + ), + ), + Match.when("thread", () => createThread(model, messageId)), + Match.when("pin", () => togglePin(model, messageId)), + Match.when("copy", () => copy(model, message?.content ?? "", "MessageText")), + Match.when("copy-id", () => copy(model, messageId, "MessageId")), + Match.when("delete", () => ({ model, - commands: actionCommands([CopyText({ text: url })]), - outMessage: toast(successToast("URL copied", "Image URL has been copied to clipboard.")), + outMessage: PageOutMessage.RequestedModal({ modal: { _tag: "DeleteMessage", messageId } }), })), - Match.when("openInBrowser", () => ({ model, commands: actionCommands([OpenUrl({ url })]) })), + Match.when("add-reaction", () => openReactionModal(model, messageId)), Match.exhaustive, ) +} /** The toolbar, menus and context menu's actions (`useMessageActions`). */ export const handleOverlaysOut = (model: Model, out: Overlays.OutMessage): PageReturn => Overlays.OutMessage.match<PageReturn>(out, { RequestedReaction: ({ messageId, emoji }) => react(model, messageId, emoji), RequestedViewerAction: ({ action, url, fileName }) => viewerAction(model, action, url, fileName), - RequestedMessageAction: ({ messageId, action }) => { - const message = findMessage(model, messageId) - if (action === "reply") return { model: { ...model, draft: DraftUpdate.setReply(model.draft, messageId) } } - if (action === "edit") - return message === undefined - ? { model } - : liftDraft(model, "channel", DraftUpdate.startEditing(model.draft, messageId, message.content)) - if (action === "thread") return createThread(model, messageId) - if (action === "pin") return togglePin(model, messageId) - if (action === "copy") - return copy(model, message?.content ?? "", "Message content has been copied to your clipboard.") - if (action === "copy-id") return copy(model, messageId, "Message ID has been copied to your clipboard.") - if (action === "delete") - return { model, outMessage: PageOutMessage.RequestedModal({ modal: { _tag: "DeleteMessage", messageId } }) } - // "add-reaction": the context menu's emoji picker modal. - const opened = Overlays.openReactionModal(model.overlays, messageId) - return { - model: { ...model, overlays: opened.model }, - commands: Command.mapMessages(opened.commands ?? [], (message) => Message.GotOverlaysMessage({ message })), - } - }, + RequestedMessageAction: ({ messageId, action }) => messageAction(model, messageId, action), }) const withToast = (model: Model, request: ToastRequest | null): PageReturn => @@ -241,54 +380,112 @@ export const handleActionMessage = (model: Model, message: ActionMessage): PageR // The panel opens at once on the optimistic id; the composer waits for the RPC. CompletedGenerateThreadChannelId: ({ messageId, threadChannelId }) => { if (model.currentUserId === null || model.channel === null) return { model } - const opened = openThread(model, threadChannelId, messageId) - return { - model: { ...opened.model, pendingThreadChannelId: threadChannelId }, - commands: [ - ...(opened.commands ?? []), - ...actionCommands([ - CreateThread({ - threadChannelId, - messageId, - parentChannelId: model.channelId, - organizationId: model.channel.organizationId, - currentUserId: model.currentUserId, - }), - ]), - ], - } - }, - SucceededCreateThread: () => ({ model: { ...model, pendingThreadChannelId: null } }), - // Close the panel on failure. - FailedCreateThread: ({ threadChannelId, toast: request }) => { - const closed = syncThreadDraft({ - ...model, - pendingThreadChannelId: null, - overlays: Overlays.closeThread(model.overlays, threadChannelId), + const create = CreateThread({ + threadChannelId, + messageId, + parentChannelId: model.channelId, + organizationId: model.channel.organizationId, + currentUserId: model.currentUserId, }) - return { ...closed, outMessage: toast(request) } + const opened = openThread(model, threadChannelId, messageId, "Creating") + return { ...opened, commands: [...(opened.commands ?? []), ...actionCommands([create])] } }, - CompletedCopyText: () => ({ model }), + // Only the panel still showing this thread stops waiting. + SucceededCreateThread: ({ threadChannelId }) => ({ + model: + model.threadPanel?.threadChannelId === threadChannelId + ? withPanel(model, (panel) => modifyFields(panel, { creation: () => "Created" as const })) + : model, + }), + // The panel on the thread that failed closes; the failure is reported either way. + FailedCreateThread: ({ threadChannelId, reason }) => + andThen( + model.threadPanel?.threadChannelId === threadChannelId ? closeThread(model) : { model }, + (next) => + withToast( + next, + errorToastOf( + failureMessage(reason, { + MessageNotFound: { + title: "Message not found", + description: "The message no longer exists", + }, + NestedThread: { + title: "Cannot create thread", + description: "Threads cannot be created within threads", + }, + }), + ), + ), + ), + SucceededCopyText: ({ content }) => withToast(model, copiedToast(content)), + FailedCopyText: () => + withToast(model, errorToast("Couldn't copy", "Your browser blocked access to the clipboard.")), CompletedDownloadImage: () => ({ model }), CompletedOpenUrl: () => ({ model }), - CompletedGenerateThreadName: ({ toast: request }) => - withToast({ ...model, isGeneratingThreadName: false }, request), + SucceededGenerateThreadName: ({ threadChannelId }) => ({ + model: namedThread(model, threadChannelId), + }), + FailedGenerateThreadName: ({ threadChannelId, reason }) => + withToast( + namedThread(model, threadChannelId), + errorToastOf( + failureMessage(reason, { + ThreadNotFound: { + title: "Thread not found", + description: "This thread may have been deleted.", + }, + OriginalMessageNotFound: { + title: "Original message not found", + description: "The message that started this thread could not be found.", + }, + ThreadContextUnavailable: { + title: "Database error", + description: "Failed to load thread data. Please try again.", + }, + AIUnavailable: { + title: "AI service unavailable", + description: "The AI service is temporarily unavailable. Please try again later.", + }, + AIRateLimited: { + title: "AI rate limited", + description: "Please wait a moment and try again.", + }, + AIResponseInvalid: { + title: "AI response error", + description: "The AI returned an unexpected response. Please try again.", + }, + SaveFailed: { + title: "Update failed", + description: "Failed to save the thread name. Please try again.", + }, + }), + ), + ), + // The members query brings the membership; the banner then gives way to the composer. + SucceededJoinChannel: () => + withToast( + modifyFields(model, { isJoining: () => false }), + successToast("Successfully joined channel"), + ), + FailedJoinChannel: ({ reason }) => + withToast( + modifyFields(model, { isJoining: () => false }), + errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Channel not found", + description: "This channel may have been deleted.", + }, + }), + ), + ), }) -/** The thread panel header's "Generate thread name". */ -export const generateThreadName = (model: Model): PageReturn => { - const thread = model.overlays.thread - if (thread === null || model.isGeneratingThreadName) return { model } - return { - model: { ...model, isGeneratingThreadName: true }, - commands: actionCommands([GenerateThreadName({ channelId: thread.threadChannelId })]), - } -} - /** `useGlobalKeyboardFocus`: the typed character goes into the focused draft's editor. */ export const insertGlobalKey = (model: Model, key: string): PageReturn => { const which = focusedDraft(model) - const draft = which === "channel" ? model.draft : model.threadDraft + const draft = draftOf(model, which) if (draft === null || draft.composer.commandInput !== null) return { model } return { model, @@ -301,21 +498,3 @@ export const insertGlobalKey = (model: Model, key: string): PageReturn => { ), } } - -/** The thread panel's draft follows the open thread (a new panel, a new `ChatProvider`). */ -export const syncThreadDraft = (model: Model): PageReturn => { - const thread = model.overlays.thread - if (model.threadDraft?.channelId === thread?.threadChannelId) return { model } - // `useTyping` deletes the indicator when the panel's composer unmounts. - const stopCommands = model.threadDraft === null ? [] : (Typing.stop(model.threadDraft.typing).commands ?? []) - return { - model: { - ...model, - threadDraft: thread === null ? null : newThreadDraft(model, thread.threadChannelId), - threadMemberId: null, - }, - commands: Command.mapMessages(stopCommands, (message) => - Message.GotThreadDraftMessage({ message: Draft.Message.GotTypingMessage({ message }) }), - ), - } -} diff --git a/apps/web-foldkit/src/page/chat/composer-view.ts b/apps/web-foldkit/src/page/chat/composer-view.ts index a6d99cf92..00be08214 100644 --- a/apps/web-foldkit/src/page/chat/composer-view.ts +++ b/apps/web-foldkit/src/page/chat/composer-view.ts @@ -9,15 +9,21 @@ import type { Model } from "./channel/model" /** The channel's composer area (`$id/index.tsx` > `SlateMessageComposer`) and its reply preview. */ /** The reply a draft is composing. A fresh object each call: memoized views compare its fields. */ -export const replyPreviewOf = (model: Model, draft: Draft.Model): ReplyPreview | null => { +export const replyPreviewOf = ( + model: Pick<Model, "messages" | "threadPanel" | "lookups" | "currentUserId">, + draft: Draft.Model, +): ReplyPreview | null => { const replyTo = draft.replyToMessageId const message = replyTo === null ? undefined : (model.messages.find((candidate) => candidate.id === replyTo) ?? - model.threadMessages.find((candidate) => candidate.id === replyTo)) + model.threadPanel?.messages.find((candidate) => candidate.id === replyTo)) if (message === undefined) return null - const author = identityOf(toDeriveContext(model.lookups, model.currentUserId ?? undefined), message.authorId) + const author = identityOf( + toDeriveContext(model.lookups, model.currentUserId ?? undefined), + message.authorId, + ) return { authorName: author?.displayName ?? "", firstLine: message.content.split("\n")[0] ?? "" } } @@ -28,5 +34,9 @@ export const attachmentInfoFrom = attachments.find((attachment) => attachment.id === id) ?? null /** The padded composer area: the typing indicator above the draft's composer. */ -export const composerAreaView = <M>(h: HtmlBuilder<M>, typing: Html, draft: Draft.Model, inputs: DraftViewInputs<M>): Html => - h.div([h.Class("relative shrink-0 px-4 pb-4 pt-2.5")], [typing, draftView(h, draft, inputs)]) +export const composerAreaView = <M>( + h: HtmlBuilder<M>, + typing: Html, + draft: Draft.Model, + inputs: DraftViewInputs<M>, +): Html => h.div([h.Class("relative shrink-0 px-4 pb-4 pt-2.5")], [typing, draftView(h, draft, inputs)]) diff --git a/apps/web-foldkit/src/page/chat/data.ts b/apps/web-foldkit/src/page/chat/data.ts index 344caf6ba..b71225c28 100644 --- a/apps/web-foldkit/src/page/chat/data.ts +++ b/apps/web-foldkit/src/page/chat/data.ts @@ -27,29 +27,6 @@ import type { ChatMessage, ChatReaction } from "./rows" * builders of `routes/_app/$orgSlug/chat/$id.tsx` behind `liveQueryStream`. */ -export const messagesStream = <Message>( - channelId: ChannelId, - limit: number, - toMessage: (messages: ReadonlyArray<ChatMessage>) => Message, -): Stream.Stream<Message> => - liveQueryStream<MessageQueryRow, Message>( - (q) => - q - .from({ message: messageCollection }) - .leftJoin({ pinned: pinnedMessageCollection }, ({ message, pinned }) => - eq(message.id, pinned.messageId), - ) - .leftJoin({ author: userCollection }, ({ message, author }) => - eq(message.authorId, author.id), - ) - .where(({ message }) => eq(message.channelId, channelId)) - .select(({ message, pinned, author }) => ({ ...message, pinnedMessage: pinned, author })) - .orderBy(({ message }) => message.createdAt, "desc") - .limit(limit) - .offset(0), - (rows) => toMessage(rows.map(toChatMessage)), - ) - /** Messages by channel, so a channel's share of the loaded messages is a lookup, not a scan. */ const messagesByChannel = messageCollection.createIndex((row) => row.channelId, { indexType: BasicIndex, @@ -66,7 +43,7 @@ const loadsWholeChannel = (channelId: ChannelId) => messagesByChannel.equalityLookup(channelId).size * 5 <= messageCollection.size /** - * The window of `messagesStream` as change sets: `offset` newest messages skipped, `limit` kept. + * The channel's messages, newest first, as change sets: `offset` newest messages skipped, `limit` kept. * One query per channel follows the pages `MessageWindows` publishes; only changed rows are converted. */ export const messageChangesStream = <Message>( @@ -94,7 +71,7 @@ const messageQueryStream = <Message>( windows: Stream.Stream<LiveQueryWindow>, toMessage: (changes: { order: ReadonlyArray<MessageId>; upserts: ReadonlyArray<ChatMessage> }) => Message, ): Stream.Stream<Message> => - liveQueryChangeSetStream<MessageQueryRow, Message>( + liveQueryChangeSetStream<MessageQueryRow, Message, MessageId>( (q, { limit, offset }) => { const isWholeChannel = loadsWholeChannel(channelId) return q @@ -118,8 +95,7 @@ const messageQueryStream = <Message>( readWindow, windows, (row) => row.id, - ({ order, upserts }) => - toMessage({ order: order as ReadonlyArray<MessageId>, upserts: upserts.map(toChatMessage) }), + ({ order, upserts }) => toMessage({ order, upserts: upserts.map(toChatMessage) }), ) export const reactionsStream = <Message>( diff --git a/apps/web-foldkit/src/page/chat/derive.ts b/apps/web-foldkit/src/page/chat/derive.ts index b4a020973..557ddfe52 100644 --- a/apps/web-foldkit/src/page/chat/derive.ts +++ b/apps/web-foldkit/src/page/chat/derive.ts @@ -1,3 +1,4 @@ +import { Schema, Struct } from "effect" import type { ChannelId, MessageId, UserId } from "@hazel/schema" import { extractGiphyMediaUrl, @@ -15,12 +16,12 @@ import { type AuthorIdentity, type ChatMessage, type ChatReaction, - deepEqual, type DisplayRow, type GroupPosition, type LiveRow, type MarkdownRefs, - type MessageRowData, + MessageRowData, + type MessageRow, type ReplyPreview, type StatusEmoji, type ThreadPreview, @@ -190,7 +191,8 @@ const urlEmbedsOf = (context: DeriveContext, message: ChatMessage): ReadonlyArra const embeds: UrlEmbed[] = [] for (const url of urls.tweetUrls) { const tweetId = extractTweetId(url) - if (tweetId) embeds.push({ _tag: "Tweet", url, tweetId, unfurl: context.unfurls[tweetKey(tweetId)] ?? null }) + if (tweetId) + embeds.push({ _tag: "Tweet", url, tweetId, unfurl: context.unfurls[tweetKey(tweetId)] ?? null }) } for (const url of urls.youtubeUrls) { const videoId = extractYoutubeVideoId(url) @@ -206,7 +208,11 @@ const urlEmbedsOf = (context: DeriveContext, message: ChatMessage): ReadonlyArra } const previewUrl = urls.otherUrls[urls.otherUrls.length - 1] if (previewUrl) - embeds.push({ _tag: "LinkPreview", url: previewUrl, unfurl: context.unfurls[linkPreviewKey(previewUrl)] ?? null }) + embeds.push({ + _tag: "LinkPreview", + url: previewUrl, + unfurl: context.unfurls[linkPreviewKey(previewUrl)] ?? null, + }) return embeds } @@ -313,28 +319,62 @@ export const toDisplayRows = ( reactionsByMessage.get(message.id) ?? NO_REACTIONS, context, ) - const old = previousByKey.get(message.id) - rows.push( - old !== undefined && old._tag === "MessageRow" && old.message === message && sameData(old, data) - ? old - : { _tag: "MessageRow", ...data }, - ) + rows.push(sharedRow(previousByKey.get(message.id), data)) } return rows } -const sameData = (old: MessageRowData, data: MessageRowData) => - old.groupPosition === data.groupPosition && - deepEqual(old.reactions, data.reactions) && - deepEqual(old.author, data.author) && - deepEqual(old.status, data.status) && - old.isDiscordSynced === data.isDiscordSynced && - deepEqual(old.reply, data.reply) && - deepEqual(old.thread, data.thread) && - deepEqual(old.attachments, data.attachments) && - deepEqual(old.refs, data.refs) && - deepEqual(old.urlEmbeds, data.urlEmbeds) && - deepEqual(old.live, data.live) +/** Row data equality past the message, which callers compare by reference first. */ +const isSameRowData = Schema.toEquivalence(Schema.Struct(Struct.omit(MessageRowData.fields, ["message"]))) + +/** The previous row when nothing it shows changed, so its memo hits; else a new row. */ +const sharedRow = (old: DisplayRow | undefined, data: MessageRowData): MessageRow => + old !== undefined && old._tag === "MessageRow" && old.message === data.message && isSameRowData(old, data) + ? old + : { _tag: "MessageRow", ...data } + +/** `ThreadMessageListContent`'s grouping (oldest first). */ +const threadGroupPositionAt = (messages: ReadonlyArray<ChatMessage>, index: number): GroupPosition => { + const message = messages[index]! + const previous = index > 0 ? messages[index - 1]! : null + const next = index < messages.length - 1 ? messages[index + 1]! : null + const isStart = + previous === null || + message.authorId !== previous.authorId || + message.createdAtMs - previous.createdAtMs > GROUP_THRESHOLD_MS || + previous.replyToMessageId !== null + const isEnd = + next === null || + message.authorId !== next.authorId || + next.createdAtMs - message.createdAtMs > GROUP_THRESHOLD_MS + return toGroupPosition(isStart, isEnd) +} + +/** + * The thread panel's rows (oldest first, no reactions or nested threads), sharing every unchanged + * row with `previousRows`; the same array comes back when no row changed. + */ +export const toThreadRows = ( + messagesOldestFirst: ReadonlyArray<ChatMessage>, + context: DeriveContext, + previousRows: ReadonlyArray<MessageRow>, +): ReadonlyArray<MessageRow> => { + const previousByKey = new Map(previousRows.map((row) => [row.key, row])) + const rows = messagesOldestFirst.map((message, index) => + sharedRow(previousByKey.get(message.id), { + ...messageRowData( + message, + threadGroupPositionAt(messagesOldestFirst, index), + NO_REACTIONS, + context, + ), + thread: null, + }), + ) + return rows.length === previousRows.length && rows.every((row, index) => row === previousRows[index]) + ? previousRows + : rows +} /** Ids the reply and thread lookups need for this window. */ export const replyIdsOf = (messages: ReadonlyArray<ChatMessage>): ReadonlyArray<MessageId> => diff --git a/apps/web-foldkit/src/page/chat/files/data.ts b/apps/web-foldkit/src/page/chat/files/data.ts index f96a0933d..02ab8dbc3 100644 --- a/apps/web-foldkit/src/page/chat/files/data.ts +++ b/apps/web-foldkit/src/page/chat/files/data.ts @@ -9,7 +9,7 @@ import { type AttachmentQueryRow, type Breakpoint, type FileAttachment, toFileAt export const attachmentsStream = <Message>( channelId: ChannelId, - toMessage: (attachments: ReadonlyArray<FileAttachment>, nowMs: number) => Message, + toMessage: (attachments: ReadonlyArray<FileAttachment>) => Message, ): Stream.Stream<Message> => liveQueryStream<AttachmentQueryRow, ReadonlyArray<AttachmentQueryRow>>( (q) => @@ -27,10 +27,7 @@ export const attachmentsStream = <Message>( ) .orderBy(({ attachments }) => attachments.uploadedAt, "desc"), (rows) => rows, - ).pipe( - // Legacy reads `new Date()` at render; the emit time is the closest pure equivalent. - Stream.map((rows) => toMessage(rows.map(toFileAttachment), Date.now())), - ) + ).pipe(Stream.map((rows) => toMessage(rows.map(toFileAttachment)))) const queries: ReadonlyArray<readonly [Breakpoint, string]> = [ ["xl", "(min-width: 1280px)"], diff --git a/apps/web-foldkit/src/page/chat/files/media-view.ts b/apps/web-foldkit/src/page/chat/files/media-view.ts index 12201b2cd..50096fd23 100644 --- a/apps/web-foldkit/src/page/chat/files/media-view.ts +++ b/apps/web-foldkit/src/page/chat/files/media-view.ts @@ -5,7 +5,8 @@ import { AppRoute, hrefOf } from "../../../route" import type * as Interaction from "../../../ui/aria/interaction" import { button } from "../../../ui/button" import { isVideo, isViewerImage, mediaOf, visibleCountFor } from "./derive" -import { Message, type Model } from "./page" +import { imageViewerView } from "../../../chat/image-viewer" +import { Message, type Model, viewerImagesOf } from "./page" import type { FileAttachment } from "./queries" /** Ports of `ChannelFilesMediaGrid` (one row plus "See all") and `MediaGalleryView` (masonry). */ @@ -13,6 +14,7 @@ import type { FileAttachment } from "./queries" export interface FilesContext<M> { readonly h: HtmlBuilder<M> readonly model: Model + readonly nowMs: number readonly toParentMessage: (message: Message) => M readonly wiring: Interaction.Wiring<M> } @@ -158,7 +160,7 @@ const seeAllItem = <M>( ) } -/** `ChannelFilesMediaGrid`; the image viewer modal is not ported (see `viewerImageId`). */ +/** `ChannelFilesMediaGrid`; an image opens `ImageViewerModal` (`imageViewerOverlay`). */ export const mediaGridView = <M>( context: FilesContext<M>, attachments: ReadonlyArray<FileAttachment>, @@ -226,3 +228,35 @@ export const mediaGalleryView = <M>(context: FilesContext<M>): Html => { ], ) } + +/** `ImageViewerModal` over the channel's images, open on `viewerImageId`. */ +export const imageViewerOverlay = <M>( + h: HtmlBuilder<M>, + model: Model, + toParentMessage: (message: Message) => M, +): Html => { + const images = viewerImagesOf(model) + const index = images.findIndex((image) => image.id === model.viewerImageId) + const image = images[index] + if (image === undefined) return h.empty + const uploader = image.uploader + return imageViewerView(h, { + images, + index, + author: + uploader === null + ? null + : { + name: `${uploader.firstName} ${uploader.lastName}`, + avatarUrl: uploader.avatarUrl, + seed: `${uploader.firstName} ${uploader.lastName}`, + }, + createdAtMs: image.uploadedAtMs, + toClose: () => toParentMessage(Message.ClosedImageViewer()), + toSelect: (selected) => toParentMessage(Message.SelectedViewerImage({ index: selected })), + toAction: (action, selected) => + toParentMessage( + Message.ClickedViewerAction({ action, url: selected.url, fileName: selected.fileName }), + ), + }) +} diff --git a/apps/web-foldkit/src/page/chat/files/page.test.ts b/apps/web-foldkit/src/page/chat/files/page.test.ts index 7ca5a77e6..12a8c0b6d 100644 --- a/apps/web-foldkit/src/page/chat/files/page.test.ts +++ b/apps/web-foldkit/src/page/chat/files/page.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { describe, expect, test, vi } from "vitest" import * as Select from "../../../ui/select" import { documentsOf, filterAttachments, formatRelativeTime, mediaOf, visibleCountFor } from "./derive" -import { init, Message, setView, update } from "./page" +import { init, Message, OutMessage, setView, update, viewerImagesOf } from "./page" import type { FileAttachment } from "./queries" /** Files tab update loop and derivations: search, type filter, relative dates, route resets. */ @@ -36,8 +36,7 @@ const files = [ ] const loaded = () => - update(init(channelId, "hazel", "files"), Message.UpdatedAttachments({ attachments: files, nowMs: NOW })) - .model + update(init(channelId, "hazel", "files"), Message.UpdatedAttachments({ attachments: files })).model describe("filterAttachments", () => { test("search is case-insensitive on the file name", () => { @@ -99,6 +98,31 @@ describe("update", () => { expect(media.attachments).toBe(searched.attachments) }) + test("an image opens the viewer over the channel's images; a video opens a tab instead", () => { + const image = files[0]! + const opened = update( + loaded(), + Message.ClickedMedia({ attachmentId: image.id, isVideo: false }), + ).model + expect(opened.viewerImageId).toBe(image.id) + expect(viewerImagesOf(opened).map((attachment) => attachment.id)).toEqual([image.id]) + const action = update( + opened, + Message.ClickedViewerAction({ action: "download", url: image.url, fileName: image.fileName }), + ) + expect(action.outMessage).toEqual( + OutMessage.RequestedViewerAction({ + action: "download", + url: image.url, + fileName: image.fileName, + }), + ) + expect(update(opened, Message.ClosedImageViewer()).model.viewerImageId).toBeNull() + const video = update(loaded(), Message.ClickedMedia({ attachmentId: files[1]!.id, isVideo: true })) + expect(video.model.viewerImageId).toBeNull() + expect(video.commands?.map((command) => command.name)).toEqual(["OpenMediaTab"]) + }) + test("the grid row size follows the breakpoint", () => { const resized = update(loaded(), Message.ResizedViewport({ breakpoint: "xl" })).model expect(visibleCountFor(resized.breakpoint)).toBe(5) diff --git a/apps/web-foldkit/src/page/chat/files/page.ts b/apps/web-foldkit/src/page/chat/files/page.ts index 833f50544..5671766c0 100644 --- a/apps/web-foldkit/src/page/chat/files/page.ts +++ b/apps/web-foldkit/src/page/chat/files/page.ts @@ -7,7 +7,8 @@ import { modifyFields } from "foldkit/struct" import * as Interaction from "../../../ui/aria/interaction" import { searchFieldIds } from "../../../ui/search-field" import * as Select from "../../../ui/select" -import { FileFilterType, filterOptions } from "./derive" +import { FileFilterType, filterOptions, isViewerImage, mediaOf } from "./derive" +import { ViewerAction } from "../overlays" import { Breakpoint, FileAttachment } from "./queries" /** Files tab (`chat/$id/files/index.tsx`) and All Media page (`chat/$id/files/media.tsx`). */ @@ -23,15 +24,13 @@ export const Model = Schema.Struct({ view: FilesView, /** Newest first, as the query returns them. */ attachments: Schema.Array(FileAttachment), - /** Clock of the last query result, for "Today" / "Yesterday". */ - nowMs: Schema.Number, searchQuery: Schema.String, filterType: FileFilterType, filter: Select.Model, breakpoint: Breakpoint, /** Media whose `<img>`/`<video>` fired `error`; legacy renders them as nothing. */ failedMediaIds: Schema.Array(AttachmentId), - /** The image the legacy `ImageViewerModal` would show. */ + /** The image `ImageViewerModal` shows, among the channel's images. */ viewerImageId: Schema.NullOr(AttachmentId), interaction: Interaction.Model, }) @@ -40,7 +39,7 @@ export type Model = typeof Model.Type // MESSAGE export const Message = defineMessageUnion({ - UpdatedAttachments: { attachments: Schema.Array(FileAttachment), nowMs: Schema.Number }, + UpdatedAttachments: { attachments: Schema.Array(FileAttachment) }, ResizedViewport: { breakpoint: Breakpoint }, UpdatedSearch: { value: Schema.String }, ClearedSearch: {}, @@ -53,9 +52,18 @@ export const Message = defineMessageUnion({ ClickedDownload: { attachmentId: AttachmentId }, CompletedDownloadAttachment: {}, CompletedOpenMediaTab: {}, + ClosedImageViewer: {}, + SelectedViewerImage: { index: Schema.Number }, + ClickedViewerAction: { action: ViewerAction, url: Schema.String, fileName: Schema.String }, }) export type Message = typeof Message.Type +/** The viewer's toolbar actions run where the channel's do (`ImageViewerModal` is shared). */ +export const OutMessage = defineMessageUnion({ + RequestedViewerAction: { action: ViewerAction, url: Schema.String, fileName: Schema.String }, +}) +export type OutMessage = typeof OutMessage.Type + // INIT export const SEARCH_FIELD_ID = "channel-files-search" @@ -66,7 +74,6 @@ export const init = (channelId: ChannelId, orgSlug: string, view: FilesView): Mo orgSlug, view, attachments: [], - nowMs: 0, searchQuery: "", filterType: "all", filter: Select.init({ @@ -87,7 +94,6 @@ export const setView = (model: Model, view: FilesView): Model => : { ...init(model.channelId, model.orgSlug, view), attachments: model.attachments, - nowMs: model.nowMs, breakpoint: model.breakpoint, } @@ -130,7 +136,7 @@ const OpenMediaTab = Command.define("OpenMediaTab", { // UPDATE -export type FilesReturn = Update.Return<Model, Message> +export type FilesReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> const foldInteraction = Update.foldChild({ update: Interaction.update, @@ -167,8 +173,13 @@ const withAttachment = ( export const update = (model: Model, message: Message): FilesReturn => Message.match<FilesReturn>(message, { - UpdatedAttachments: ({ attachments, nowMs }) => ({ - model: modifyFields(model, { attachments: () => attachments, nowMs: () => nowMs }), + UpdatedAttachments: ({ attachments }) => ({ + model: modifyFields(model, { + attachments: () => attachments, + // A deleted image closes the viewer. + viewerImageId: (id) => + id !== null && attachments.some((attachment) => attachment.id === id) ? id : null, + }), }), ResizedViewport: ({ breakpoint }) => ({ model: @@ -201,4 +212,19 @@ export const update = (model: Model, message: Message): FilesReturn => })), CompletedDownloadAttachment: () => ({ model }), CompletedOpenMediaTab: () => ({ model }), + ClosedImageViewer: () => ({ model: modifyFields(model, { viewerImageId: () => null }) }), + SelectedViewerImage: ({ index }) => { + const image = viewerImagesOf(model)[index] + return image === undefined + ? { model } + : { model: modifyFields(model, { viewerImageId: () => image.id }) } + }, + ClickedViewerAction: ({ action, url, fileName }) => + model.viewerImageId === null + ? { model } + : { model, outMessage: OutMessage.RequestedViewerAction({ action, url, fileName }) }, }) + +/** The images the viewer pages through: every image of the channel, newest first. */ +export const viewerImagesOf = (model: Model): ReadonlyArray<FileAttachment> => + mediaOf(model.attachments).filter(isViewerImage) diff --git a/apps/web-foldkit/src/page/chat/files/subscriptions.ts b/apps/web-foldkit/src/page/chat/files/subscriptions.ts index 3ee550abc..2f1aa0b42 100644 --- a/apps/web-foldkit/src/page/chat/files/subscriptions.ts +++ b/apps/web-foldkit/src/page/chat/files/subscriptions.ts @@ -13,9 +13,7 @@ const data = Subscription.make<Model, Message>()((entry) => ({ { modelToDependencies: (model) => ({ channelId: model.channelId }), dependenciesToStream: ({ channelId }) => - attachmentsStream(channelId, (attachments, nowMs) => - Message.UpdatedAttachments({ attachments, nowMs }), - ), + attachmentsStream(channelId, (attachments) => Message.UpdatedAttachments({ attachments })), }, ), filesBreakpoint: Subscription.persistentEntry( diff --git a/apps/web-foldkit/src/page/chat/files/view.ts b/apps/web-foldkit/src/page/chat/files/view.ts index 6f7d89968..6de399530 100644 --- a/apps/web-foldkit/src/page/chat/files/view.ts +++ b/apps/web-foldkit/src/page/chat/files/view.ts @@ -50,7 +50,7 @@ const headerView = <M>(context: FilesContext<M>, fileCount: number): Html => { /** `DocumentItem` of `channel-files-documents-list.tsx`. */ const documentItem = <M>(context: FilesContext<M>, attachment: FileAttachment): Html => { - const { h, model, toParentMessage } = context + const { h, nowMs, toParentMessage } = context const uploader = uploaderIdentity(attachment.uploader) return h.keyed("div")( attachment.id, @@ -72,7 +72,7 @@ const documentItem = <M>(context: FilesContext<M>, attachment: FileAttachment): [ h.span([], [formatFileSize(attachment.fileSize)]), h.span([h.AriaHidden(true)], ["·"]), - h.span([], [formatRelativeTime(attachment.uploadedAtMs, model.nowMs)]), + h.span([], [formatRelativeTime(attachment.uploadedAtMs, nowMs)]), ], ), ], @@ -219,10 +219,17 @@ const mediaPageView = <M>(context: FilesContext<M>): Html => { ) } -export const view = <M>(h: HtmlBuilder<M>, model: Model, toParentMessage: (message: Message) => M): Html => { +/** `nowMs` is the shared clock, for "Today" / "Yesterday" (legacy reads `new Date()` at render). */ +export const view = <M>( + h: HtmlBuilder<M>, + model: Model, + toParentMessage: (message: Message) => M, + nowMs: number, +): Html => { const context: FilesContext<M> = { h, model, + nowMs, toParentMessage, wiring: { model: model.interaction, @@ -231,3 +238,5 @@ export const view = <M>(h: HtmlBuilder<M>, model: Model, toParentMessage: (messa } return model.view === "media" ? mediaPageView(context) : filesView(context) } + +export { imageViewerOverlay } from "./media-view" diff --git a/apps/web-foldkit/src/page/chat/message-actions.ts b/apps/web-foldkit/src/page/chat/message-actions.ts index c72c23a6a..b046e7845 100644 --- a/apps/web-foldkit/src/page/chat/message-actions.ts +++ b/apps/web-foldkit/src/page/chat/message-actions.ts @@ -5,20 +5,59 @@ import { defineMessageUnion } from "foldkit/message" import { createThreadAction, deleteMessageAction, + joinChannelAction, pinMessageAction, toggleReactionAction, unpinMessageAction, } from "~/db/actions" +import { CommonReason } from "~/lib/error-messages" import { ToastRequest } from "../../overlay/toasts" import { HazelRpc } from "../../rpc" -import { toastOfExit } from "../../data/actions" +import { catchCommon, toastOfExit } from "../../data/actions" import { notRetryable, rateLimited } from "./action-effects" /** * The message actions of `ChatProvider` and `useMessageActions`: each Command runs the same - * optimistic action (`db/actions.ts`) with the same variables, and reports its `exitToast`. + * optimistic action (`db/actions.ts`) with the same variables. Reactions, pins and deletes still + * report a toast (`toastOfExit`); the thread, clipboard and join Commands report a typed reason. */ +// MODEL + +/** What can go wrong creating a thread, beyond the shared reasons. */ +export const CreateThreadFailure = Schema.Union([ + Schema.Literals(["MessageNotFound", "NestedThread"]), + CommonReason, +]) +export type CreateThreadFailure = typeof CreateThreadFailure.Type + +/** What can go wrong naming a thread with AI, beyond the shared reasons. */ +export const GenerateThreadNameFailure = Schema.Union([ + Schema.Literals([ + "ThreadNotFound", + "OriginalMessageNotFound", + "ThreadContextUnavailable", + "AIUnavailable", + "AIRateLimited", + "AIResponseInvalid", + "SaveFailed", + ]), + CommonReason, +]) +export type GenerateThreadNameFailure = typeof GenerateThreadNameFailure.Type + +/** `navigator.clipboard.writeText` refused (no permission, no focus, insecure context). */ +export const CopyTextFailure = Schema.Literals(["ClipboardDenied"]) +export type CopyTextFailure = typeof CopyTextFailure.Type + +/** What a copy was of: the success toast names it. */ +export const CopiedContent = Schema.Literals(["MessageText", "MessageId", "ImageUrl"]) +export type CopiedContent = typeof CopiedContent.Type + +/** What can go wrong joining a channel, beyond the shared reasons. */ +export const JoinChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type JoinChannelFailure = typeof JoinChannelFailure.Type + // MESSAGE export const ActionMessage = defineMessageUnion({ @@ -28,17 +67,20 @@ export const ActionMessage = defineMessageUnion({ CompletedDeleteMessage: { toast: Schema.NullOr(ToastRequest) }, CompletedGenerateThreadChannelId: { messageId: MessageId, threadChannelId: ChannelId }, SucceededCreateThread: { threadChannelId: ChannelId }, - FailedCreateThread: { threadChannelId: ChannelId, toast: ToastRequest }, - CompletedCopyText: {}, + FailedCreateThread: { threadChannelId: ChannelId, reason: CreateThreadFailure }, + SucceededCopyText: { content: CopiedContent }, + FailedCopyText: { reason: CopyTextFailure }, CompletedDownloadImage: {}, CompletedOpenUrl: {}, - CompletedGenerateThreadName: { threadChannelId: ChannelId, toast: Schema.NullOr(ToastRequest) }, + SucceededGenerateThreadName: { threadChannelId: ChannelId }, + FailedGenerateThreadName: { threadChannelId: ChannelId, reason: GenerateThreadNameFailure }, + SucceededJoinChannel: {}, + FailedJoinChannel: { reason: JoinChannelFailure }, }) export type ActionMessage = typeof ActionMessage.Type /** `ActionMessage` under the Submodel name, so a parent's Got wrapper imports it as `Message`. */ export { ActionMessage as Message } - const messageNotFound = notRetryable("Message not found", "This message may have been deleted.") // COMMAND @@ -115,32 +157,38 @@ export const CreateThread = Command.define("CreateThread", { currentUserId: UserId, }, messages: [ActionMessage.SucceededCreateThread, ActionMessage.FailedCreateThread], - execute: (args) => - toastOfExit(createThreadAction(args), "exitToast", { - handlers: { - MessageNotFoundError: notRetryable("Message not found", "The message no longer exists"), - NestedThreadError: notRetryable( - "Cannot create thread", - "Threads cannot be created within threads", - ), - }, - }).pipe( - Effect.map((toast) => - toast === null - ? ActionMessage.SucceededCreateThread({ threadChannelId: args.threadChannelId }) - : ActionMessage.FailedCreateThread({ threadChannelId: args.threadChannelId, toast }), - ), - ), + execute: (args) => { + const failed = (reason: CreateThreadFailure) => + ActionMessage.FailedCreateThread({ threadChannelId: args.threadChannelId, reason }) + return createThreadAction(args).pipe( + Effect.as(ActionMessage.SucceededCreateThread({ threadChannelId: args.threadChannelId })), + Effect.catchTags({ + MessageNotFoundError: () => Effect.succeed(failed("MessageNotFound")), + NestedThreadError: () => Effect.succeed(failed("NestedThread")), + }), + catchCommon(failed), + ) + }, }) -/** `navigator.clipboard.writeText`; legacy toasts without waiting for it. */ +class ClipboardError extends Schema.TaggedError<ClipboardError>()("ClipboardError", { + cause: Schema.Defect(), +}) {} + +/** `navigator.clipboard.writeText`; the toast waits for the browser's answer. */ export const CopyText = Command.define("CopyText", { - args: { text: Schema.String }, - messages: [ActionMessage.CompletedCopyText], - execute: ({ text }) => - Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( - Effect.ignore, - Effect.as(ActionMessage.CompletedCopyText()), + args: { text: Schema.String, content: CopiedContent }, + messages: [ActionMessage.SucceededCopyText, ActionMessage.FailedCopyText], + execute: ({ text, content }) => + Effect.tryPromise({ + try: () => navigator.clipboard.writeText(text), + catch: (cause) => new ClipboardError({ cause }), + }).pipe( + Effect.as(ActionMessage.SucceededCopyText({ content })), + Effect.catchTags({ + ClipboardError: () => + Effect.succeed(ActionMessage.FailedCopyText({ reason: "ClipboardDenied" })), + }), ), }) @@ -172,44 +220,43 @@ export const OpenUrl = Command.define("OpenUrl", { }), }) -/** The thread panel's "Generate thread name" (`channel.generateName`). */ +/** The thread panel's "Generate thread name" (`channel.generateName`); the name arrives through sync. */ export const GenerateThreadName = Command.define("GenerateThreadName", { args: { channelId: ChannelId }, - messages: [ActionMessage.CompletedGenerateThreadName], - execute: ({ channelId }) => - toastOfExit( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("channel.generateName", { channelId }) + messages: [ActionMessage.SucceededGenerateThreadName, ActionMessage.FailedGenerateThreadName], + execute: ({ channelId }) => { + const failed = (reason: GenerateThreadNameFailure) => + ActionMessage.FailedGenerateThreadName({ threadChannelId: channelId, reason }) + const fail = (reason: GenerateThreadNameFailure) => () => Effect.succeed(failed(reason)) + return HazelRpc.use((rpc) => rpc("channel.generateName", { channelId })).pipe( + Effect.as(ActionMessage.SucceededGenerateThreadName({ threadChannelId: channelId })), + Effect.catchTags({ + ChannelNotFoundError: fail("ThreadNotFound"), + ThreadChannelNotFoundError: fail("ThreadNotFound"), + MessageNotFoundError: fail("OriginalMessageNotFound"), + OriginalMessageNotFoundError: fail("OriginalMessageNotFound"), + ThreadContextQueryError: fail("ThreadContextUnavailable"), + AIProviderUnavailableError: fail("AIUnavailable"), + AIRateLimitError: fail("AIRateLimited"), + AIResponseParseError: fail("AIResponseInvalid"), + ThreadNameUpdateError: fail("SaveFailed"), }), - "exitToast", - { handlers: generateNameHandlers }, - ).pipe( - Effect.map((toast) => ActionMessage.CompletedGenerateThreadName({ threadChannelId: channelId, toast })), - ), + catchCommon(failed), + ) + }, }) -const retryable = (title: string, description: string) => () => ({ title, description, isRetryable: true }) - -/** `handleGenerateName`'s `onErrorTag` and `onCommonErrorTag` overrides. */ -const generateNameHandlers = { - ChannelNotFoundError: notRetryable("Thread not found", "This thread may have been deleted."), - MessageNotFoundError: notRetryable( - "Original message not found", - "The message that started this thread could not be found.", - ), - ThreadChannelNotFoundError: notRetryable("Thread not found", "This thread may have been deleted."), - OriginalMessageNotFoundError: notRetryable("Message not found", "The original message could not be found."), - ThreadContextQueryError: retryable("Database error", "Failed to load thread data. Please try again."), - AIProviderUnavailableError: retryable( - "AI service unavailable", - "The AI service is temporarily unavailable. Please try again later.", - ), - AIRateLimitError: retryable("AI rate limited", "Please wait a moment and try again."), - AIResponseParseError: retryable( - "AI response error", - "The AI returned an unexpected response. Please try again.", - ), - ThreadNameUpdateError: retryable("Update failed", "Failed to save the thread name. Please try again."), - WorkflowServiceUnavailableError: retryable("Service temporarily unavailable", "Please try again later."), -} +/** `ChannelJoinBanner`'s "Join #channel": `joinChannelAction`, as the join modal runs it. */ +export const JoinChannel = Command.define("JoinChannel", { + args: { channelId: ChannelId, userId: UserId }, + messages: [ActionMessage.SucceededJoinChannel, ActionMessage.FailedJoinChannel], + execute: (args) => + joinChannelAction(args).pipe( + Effect.as(ActionMessage.SucceededJoinChannel()), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed(ActionMessage.FailedJoinChannel({ reason: "NotFound" })), + }), + catchCommon((reason) => ActionMessage.FailedJoinChannel({ reason })), + ), +}) diff --git a/apps/web-foldkit/src/page/chat/overlay-views.ts b/apps/web-foldkit/src/page/chat/overlay-views.ts index 89987a030..b1066ee79 100644 --- a/apps/web-foldkit/src/page/chat/overlay-views.ts +++ b/apps/web-foldkit/src/page/chat/overlay-views.ts @@ -1,7 +1,9 @@ import type { Html, HtmlBuilder } from "foldkit/html" import * as EmojiDialog from "../../emoji-picker/dialog" import { pickerView } from "../../emoji-picker/view" -import { controlledModal } from "../../ui/modal" +import type * as Picker from "../../emoji-picker/picker" +import type * as TooltipHost from "../../chat/tooltip-host" +import { controlledModal, type Message as ModalMessage } from "../../ui/modal" import { Mount } from "foldkit" import { IconDotsVertical, IconStar, IconThread } from "../../icons" import { @@ -19,18 +21,27 @@ import { factsOf, Message, type Model } from "./channel/page" /** Overlays rendered outside the rows: hover toolbar, delete confirmation, reply indicator. */ -type ToParent<M> = (message: Message) => M +type H = HtmlBuilder<Message> -const toOverlays = - <M>(toParentMessage: ToParent<M>) => - (message: Overlays.Message) => - toParentMessage(Message.GotOverlaysMessage({ message })) +const toOverlay = (message: Overlays.Message) => Message.GotOverlaysMessage({ message }) + +const toTooltipMessage = (message: TooltipHost.Message) => + toOverlay(Overlays.Message.GotTooltipMessage({ message })) + +const toToolbarMessage = (message: Toolbar.Message) => + toOverlay(Overlays.Message.GotToolbarMessage({ message })) + +const toReactionModalMessage = (message: ModalMessage) => + toOverlay(Overlays.Message.GotReactionModalMessage({ message })) + +const toReactionModalPickerMessage = (message: Picker.Message) => + toOverlay(Overlays.Message.GotReactionModalPickerMessage({ message })) /** The list container's delegated hover and context menu handling. */ -export const trackHoverAttribute = <M>(h: HtmlBuilder<M>, toParentMessage: ToParent<M>) => +export const trackHoverAttribute = (h: H) => h.OnMount( Mount.mapMessage(TrackMessageHover(), (event) => - toOverlays(toParentMessage)( + toOverlay( HoverEvent.match(event, { PointerEnteredMessage: ({ messageId }) => Overlays.Message.PointerEnteredMessage({ messageId }), @@ -47,16 +58,11 @@ export const trackHoverAttribute = <M>(h: HtmlBuilder<M>, toParentMessage: ToPar ) /** `MessageToolbar` in its body portal, for the hovered message. */ -export const messageToolbarOverlay = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: ToParent<M>, -): Html => { +export const messageToolbarOverlay = (h: H, model: Model): Html => { const overlays = model.overlays - const messageId = overlays.hoveredMessageId + const messageId = Overlays.hoveredMessageIdOf(overlays) const row = messageId === null ? undefined : model.rows.find((candidate) => candidate.key === messageId) if (messageId === null || row === undefined || row._tag !== "MessageRow") return h.empty - const toOverlay = toOverlays(toParentMessage) const facts = factsOf(model) const menu = Overlays.moreMenuFor(overlays, messageId, facts) const isPinned = facts.isPinned(messageId) @@ -129,13 +135,10 @@ export const messageToolbarOverlay = <M>( toolbarContent(h, { messageId, isOwnMessage: facts.isOwnMessage(messageId), - tooltip: overlays.tooltip, - hoveredKey: overlays.hoveredTriggerKey, - focusedKey: overlays.focusedTriggerKey, - toTooltipMessage: (tooltip) => - toOverlay( - Overlays.Message.GotTooltipMessage({ message: tooltip }), - ), + tooltip: Overlays.activeTooltipOf(overlays), + hoveredKey: overlays.hoveredTrigger?.key ?? null, + focusedKey: overlays.focusedTrigger?.key ?? null, + toTooltipMessage, onReact: (emoji) => toOverlay( Overlays.Message.ClickedReaction({ @@ -152,9 +155,7 @@ export const messageToolbarOverlay = <M>( addReaction: (render) => EmojiDialog.view( h, - overlays.reactionPicker?.messageId === messageId - ? overlays.reactionPicker.dialog - : EmojiDialog.init(`reaction-picker-${messageId}`), + Overlays.reactionPickerFor(overlays, messageId), { toMessage: (message) => toOverlay( @@ -170,8 +171,7 @@ export const messageToolbarOverlay = <M>( moreActions, }), }, - toParentMessage: (message) => - toOverlay(Overlays.Message.GotToolbarMessage({ message })), + toParentMessage: toToolbarMessage, }), ], ), @@ -182,20 +182,18 @@ export const messageToolbarOverlay = <M>( } /** The context menu's emoji picker modal (`ModalContent size="xs"` around the picker). */ -export const reactionModalView = <M>(h: HtmlBuilder<M>, model: Model, toParentMessage: ToParent<M>): Html => { - const current = model.overlays.reactionModal +export const reactionModalView = (h: H, model: Model): Html => { + const current = Overlays.reactionModalOf(model.overlays) if (current === null) return h.empty - const toOverlay = toOverlays(toParentMessage) return controlledModal(h, { model: current.modal, - toParentMessage: (message) => toOverlay(Overlays.Message.GotReactionModalMessage({ message })), + toParentMessage: toReactionModalMessage, size: "xs", closeButton: false, className: "overflow-hidden p-0!", toContent: () => [ pickerView(h, current.picker, { - toMessage: (message) => - toOverlay(Overlays.Message.GotReactionModalPickerMessage({ message })), + toMessage: toReactionModalPickerMessage, className: "h-[420px]", }), ], diff --git a/apps/web-foldkit/src/page/chat/overlays.ts b/apps/web-foldkit/src/page/chat/overlays.ts index 4b62a7eb1..9eb66af5d 100644 --- a/apps/web-foldkit/src/page/chat/overlays.ts +++ b/apps/web-foldkit/src/page/chat/overlays.ts @@ -1,7 +1,9 @@ -import { ChannelId, MessageId } from "@hazel/schema" +import { MessageId } from "@hazel/schema" import { Duration, Effect, Schema } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" +import { modifyFields } from "foldkit/struct" import * as TooltipHost from "../../chat/tooltip-host" import * as EmojiDialog from "../../emoji-picker/dialog" import * as Picker from "../../emoji-picker/picker" @@ -9,14 +11,12 @@ import * as Modal from "../../ui/modal" import * as Menu from "../../ui/menu" import * as Popover from "../../ui/popover" import * as Toolbar from "../../ui/toolbar" +import * as Tooltip from "../../ui/tooltip" /** Reading overlays anchored to messages: hover toolbar, tooltips, menus, popovers, viewers. */ // MODEL -export const MessageMenu = Schema.Struct({ messageId: MessageId, menu: Menu.Model }) -export const MessagePopover = Schema.Struct({ key: Schema.String, popover: Popover.Model }) - /** An image the viewer shows by URL (legacy `ViewerImage` `{ type: "url", url, alt }`). */ export const ViewerUrlImage = Schema.Struct({ url: Schema.String, alt: Schema.String }) export type ViewerUrlImage = typeof ViewerUrlImage.Type @@ -25,63 +25,94 @@ export type ViewerUrlImage = typeof ViewerUrlImage.Type export const ViewerAction = Schema.Literals(["download", "copyUrl", "openInBrowser"]) export type ViewerAction = typeof ViewerAction.Type +/** The kit Popover's id (and DOM slot) of a message's author popover. */ +export const avatarPopoverKey = (messageId: MessageId) => `${messageId}:avatar` + +/** A tooltip trigger: its key (the Tooltip's id) and the message it renders in. */ +export const Trigger = Schema.Struct({ key: Schema.String, messageId: MessageId }) +export type Trigger = typeof Trigger.Type + +/** + * The one overlay open on a message at a time. A menu, popover, picker or viewer opening replaces + * whatever was open, so two of them can never be up together. + */ +export const OpenOverlay = defineTaggedUnion({ + None: {}, + MoreMenu: { messageId: MessageId, menu: Menu.Model }, + ContextMenu: { messageId: MessageId, menu: Menu.Model }, + /** The author popover open from a message's avatar. */ + Popover: { messageId: MessageId, popover: Popover.Model }, + /** `urlImages` are a GIF or tweet photos (`ViewerImage` of type "url"); otherwise the attachments. */ + ImageViewer: { + messageId: MessageId, + index: Schema.Number, + urlImages: Schema.NullOr(Schema.Array(ViewerUrlImage)), + }, + /** The toolbar's "Add reaction" picker. */ + ReactionPicker: { messageId: MessageId, dialog: EmojiDialog.Model }, + /** The context menu's "Add Reaction": a modal holding the picker. */ + ReactionModal: { messageId: MessageId, modal: Modal.Model, picker: Picker.Model }, +}) +export type OpenOverlay = typeof OpenOverlay.Type + +/** + * `MessageHoverProvider`: the message under the pointer. Leaving the list starts a hide delay + * (`HidePending`) that re-entering the message cancels; the toolbar holds the message while hovered. + */ +export const Hover = defineTaggedUnion({ + None: {}, + Shown: { messageId: MessageId, isToolbarHovered: Schema.Boolean }, + HidePending: { messageId: MessageId, version: Schema.Number }, +}) +export type Hover = typeof Hover.Type + export const Model = Schema.Struct({ - /** `MessageHoverProvider`: the message under the pointer, and whether the toolbar holds it. */ - hoveredMessageId: Schema.NullOr(MessageId), - isToolbarHovered: Schema.Boolean, - /** Bumped on every hover change so a stale hide delay never acts. */ - hoverVersion: Schema.Number, - /** A hide delay is running: re-entering the hovered message has to cancel it. */ - isHidePending: Schema.Boolean, + hover: Hover, + /** Bumped for every hide delay started, so a stale one never acts. */ + hideVersion: Schema.Number, toolbar: Toolbar.Model, - tooltip: TooltipHost.Model, + /** The tooltip host's one Tooltip, with the message its trigger renders in. */ + tooltip: Schema.NullOr(Schema.Struct({ messageId: MessageId, tooltip: Tooltip.Model })), /** The tooltip trigger under the pointer (`data-hovered`). */ - hoveredTriggerKey: Schema.NullOr(Schema.String), + hoveredTrigger: Schema.NullOr(Trigger), /** The toolbar button with focus (React Aria useFocus, so `data-focused` at any modality). */ - focusedTriggerKey: Schema.NullOr(Schema.String), - moreMenu: Schema.NullOr(MessageMenu), - contextMenu: Schema.NullOr(MessageMenu), - /** The author popover open from an avatar (key `<messageId>:avatar`) or a pinned row. */ - popover: Schema.NullOr(MessagePopover), + focusedTrigger: Schema.NullOr(Trigger), + open: OpenOverlay, + /** The header's pinned messages popover. */ pinned: Popover.Model, - /** `urlImages` are a GIF or tweet photos (`ViewerImage` of type "url"); otherwise the attachments. */ - imageViewer: Schema.NullOr( - Schema.Struct({ - messageId: MessageId, - index: Schema.Number, - urlImages: Schema.NullOr(Schema.Array(ViewerUrlImage)), - }), - ), - /** The toolbar's "Add reaction" picker while it is open. */ - reactionPicker: Schema.NullOr(Schema.Struct({ messageId: MessageId, dialog: EmojiDialog.Model })), - /** The context menu's "Add Reaction": a modal holding the picker. */ - reactionModal: Schema.NullOr( - Schema.Struct({ messageId: MessageId, modal: Modal.Model, picker: Picker.Model }), - ), - /** `useChatThread`: the thread panel beside the channel. */ - thread: Schema.NullOr(Schema.Struct({ threadChannelId: ChannelId, messageId: MessageId })), }) export type Model = typeof Model.Type export const init = (): Model => ({ - hoveredMessageId: null, - isToolbarHovered: false, - hoverVersion: 0, - isHidePending: false, + hover: Hover.None(), + hideVersion: 0, toolbar: Toolbar.init({ label: "Message actions" }), tooltip: null, - hoveredTriggerKey: null, - focusedTriggerKey: null, - moreMenu: null, - contextMenu: null, - popover: null, + hoveredTrigger: null, + focusedTrigger: null, + open: OpenOverlay.None(), pinned: Popover.init("pinned-messages"), - imageViewer: null, - reactionPicker: null, - reactionModal: null, - thread: null, }) +/** The message the toolbar shows for. */ +export const hoveredMessageIdOf = (model: Model): MessageId | null => + model.hover._tag === "None" ? null : model.hover.messageId + +/** The active tooltip as the host renders it. */ +export const activeTooltipOf = (model: Model): TooltipHost.Model => model.tooltip?.tooltip ?? null + +export const imageViewerOf = (model: Model) => (model.open._tag === "ImageViewer" ? model.open : null) + +export const reactionModalOf = (model: Model) => (model.open._tag === "ReactionModal" ? model.open : null) + +export const contextMenuOf = (model: Model) => (model.open._tag === "ContextMenu" ? model.open : null) + +/** The open popover in the shape the popover host takes. */ +export const activePopoverOf = (model: Model) => + model.open._tag === "Popover" + ? { key: avatarPopoverKey(model.open.messageId), popover: model.open.popover } + : null + // MESSAGE export const Message = defineMessageUnion({ @@ -95,7 +126,7 @@ export const Message = defineMessageUnion({ GotMoreMenuMessage: { messageId: MessageId, message: Menu.Message }, RightClickedMessage: { messageId: MessageId, offset: Schema.Number, crossOffset: Schema.Number }, GotContextMenuMessage: { message: Menu.Message }, - GotPopoverMessage: { popoverId: Schema.String, message: Popover.Message }, + GotPopoverMessage: { messageId: MessageId, message: Popover.Message }, GotPinnedMessage: { message: Popover.Message }, ClickedReply: { messageId: MessageId }, ClickedDelete: { messageId: MessageId }, @@ -110,8 +141,6 @@ export const Message = defineMessageUnion({ GotReactionPickerMessage: { messageId: MessageId, message: EmojiDialog.Message }, GotReactionModalMessage: { message: Modal.Message }, GotReactionModalPickerMessage: { message: Picker.Message }, - ClickedThreadPreview: { threadChannelId: ChannelId, messageId: MessageId }, - ClosedThread: {}, }) export type Message = typeof Message.Type @@ -157,8 +186,6 @@ const WaitForHideToolbar = Command.define("WaitForHideToolbar", { export type OverlaysReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> -const set = (model: Model, fields: Partial<Model>): OverlaysReturn => ({ model: { ...model, ...fields } }) - const action = (model: Model, messageId: MessageId, requested: MessageAction): OverlaysReturn => ({ model, outMessage: OutMessage.RequestedMessageAction({ messageId, action: requested }), @@ -177,14 +204,28 @@ const withSelection = ( : result } -const mapped = <Child, ChildMessage>( +const isMenuOpen = (menu: Menu.Model) => menu.popup._tag === "Open" + +/** + * Folds a child overlay's result into `open`. The child is the open overlay when it is the current + * one, or when it just opened; a closed child that is not current (a late transition of a menu that + * another overlay replaced) leaves `open` alone. Its Commands still run. + */ +const withChild = <Child, ChildMessage>( model: Model, result: Pick<Update.Return<Child, ChildMessage>, "model" | "commands">, - write: (child: Child) => Partial<Model>, - wrap: (message: ChildMessage) => Message, + options: { + readonly isCurrent: boolean + readonly isOpen: (child: Child) => boolean + readonly toOpen: (child: Child) => OpenOverlay + readonly wrap: (message: ChildMessage) => Message + }, ): OverlaysReturn => ({ - model: { ...model, ...write(result.model) }, - commands: Command.mapMessages(result.commands ?? [], wrap), + model: + options.isCurrent || options.isOpen(result.model) + ? modifyFields(model, { open: () => options.toOpen(result.model) }) + : model, + commands: Command.mapMessages(result.commands ?? [], options.wrap), }) export const moreMenuEntries = (facts: MessageFacts): ReadonlyArray<Menu.Entry> => [ @@ -207,86 +248,169 @@ export const contextMenuEntries = (facts: MessageFacts, messageId: MessageId): R Menu.item("copy-id"), ] -/** The closed "More actions" menu of a message. */ +/** The "More actions" menu of a message: the open one, or a closed one. */ export const moreMenuFor = (model: Model, messageId: MessageId, facts: MessageFacts): Menu.Model => - model.moreMenu !== null && model.moreMenu.messageId === messageId - ? model.moreMenu.menu + model.open._tag === "MoreMenu" && model.open.messageId === messageId + ? model.open.menu : Menu.init({ id: `more-${messageId}`, entries: moreMenuEntries(facts), placement: "bottom end" }) +/** The toolbar's reaction picker of a message: the open one, or a closed one. */ +export const reactionPickerFor = (model: Model, messageId: MessageId): EmojiDialog.Model => + model.open._tag === "ReactionPicker" && model.open.messageId === messageId + ? model.open.dialog + : EmojiDialog.init(`reaction-picker-${messageId}`) + +const isSameTrigger = (a: Trigger | null, b: Trigger | null) => + a === b || (a !== null && b !== null && a.key === b.key && a.messageId === b.messageId) + +/** Where the host's hovered or focused trigger moves for a tooltip message. */ +const nextTrigger = ( + current: Trigger | null, + { key, messageId, message }: TooltipHost.Message, + enter: Tooltip.Message["_tag"], + leave: Tooltip.Message["_tag"], +): Trigger | null => + message._tag === enter + ? { key, messageId } + : message._tag === leave && current?.key === key + ? null + : current + +/** `MessageHoverProvider`'s transitions. */ +const hoverHandlers = (model: Model) => ({ + // `pointerover` fires for every child entered: within the hovered message only a pending hide changes. + PointerEnteredMessage: ({ messageId }: { readonly messageId: MessageId }): OverlaysReturn => + Hover.match(model.hover, { + None: () => ({ + model: modifyFields(model, { + hover: () => Hover.Shown({ messageId, isToolbarHovered: false }), + }), + }), + Shown: (shown) => + shown.messageId === messageId + ? { model } + : { model: modifyFields(model, { hover: () => Hover.Shown({ ...shown, messageId }) }) }, + HidePending: () => ({ + model: modifyFields(model, { + hover: () => Hover.Shown({ messageId, isToolbarHovered: false }), + }), + }), + }), + // The viewer is a portal inside the list in legacy and covers the screen, so React sees no + // leave while it is open, even when the pointer moves before the viewer has rendered. + PointerLeftList: (): OverlaysReturn => { + const hover = model.hover + if (hover._tag !== "Shown" || hover.isToolbarHovered || model.open._tag === "ImageViewer") + return { model } + const version = model.hideVersion + 1 + return { + model: modifyFields(model, { + hideVersion: () => version, + hover: () => Hover.HidePending({ messageId: hover.messageId, version }), + }), + commands: [WaitForHideToolbar({ version })], + } + }, + // The toolbar stays while its emoji picker is open (the picker is inside it). + CompletedWaitForHideToolbar: ({ version }: { readonly version: number }): OverlaysReturn => { + const hover = model.hover + if (hover._tag !== "HidePending" || hover.version !== version) return { model } + return { + model: modifyFields(model, { + hover: () => + model.open._tag === "ReactionPicker" + ? Hover.Shown({ messageId: hover.messageId, isToolbarHovered: false }) + : Hover.None(), + }), + } + }, + // Entering the toolbar cancels a pending hide. + EnteredToolbar: (): OverlaysReturn => { + const hover = model.hover + return hover._tag === "None" + ? { model } + : { + model: modifyFields(model, { + hover: () => Hover.Shown({ messageId: hover.messageId, isToolbarHovered: true }), + }), + } + }, + LeftToolbar: (): OverlaysReturn => { + const hover = model.hover + return hover._tag === "Shown" && hover.isToolbarHovered + ? { + model: modifyFields(model, { + hover: () => Hover.Shown({ messageId: hover.messageId, isToolbarHovered: false }), + }), + } + : { model } + }, +}) + export const update = (model: Model, message: Message, facts: MessageFacts): OverlaysReturn => Message.match<OverlaysReturn>(message, { - // `pointerover` fires for every child entered: within the hovered message only a pending hide changes. - PointerEnteredMessage: ({ messageId }) => - model.hoveredMessageId === messageId && !model.isHidePending - ? { model } - : set(model, { - hoveredMessageId: messageId, - hoverVersion: model.hoverVersion + 1, - isHidePending: false, - }), - // The viewer is a portal inside the list in legacy and covers the screen, so React sees no - // leave while it is open, even when the pointer moves before the viewer has rendered. - PointerLeftList: () => { - if (model.hoveredMessageId === null || model.isToolbarHovered || model.imageViewer !== null) - return { model } - const version = model.hoverVersion + 1 + ...hoverHandlers(model), + GotToolbarMessage: ({ message: toolbarMessage }) => { + const result = Toolbar.update(model.toolbar, toolbarMessage) return { - model: { ...model, hoverVersion: version, isHidePending: true }, - commands: [WaitForHideToolbar({ version })], + model: modifyFields(model, { toolbar: () => result.model }), + commands: Command.mapMessages(result.commands ?? [], (inner) => + Message.GotToolbarMessage({ message: inner }), + ), } }, - // The toolbar stays while its emoji picker is open (the picker is inside it). - CompletedWaitForHideToolbar: ({ version }) => - version !== model.hoverVersion - ? { model } - : !model.isToolbarHovered && model.reactionPicker === null - ? set(model, { hoveredMessageId: null, isHidePending: false }) - : set(model, { isHidePending: false }), - EnteredToolbar: () => - set(model, { isToolbarHovered: true, hoverVersion: model.hoverVersion + 1, isHidePending: false }), - LeftToolbar: () => set(model, { isToolbarHovered: false }), - GotToolbarMessage: ({ message: toolbarMessage }) => - mapped( - model, - Toolbar.update(model.toolbar, toolbarMessage), - (toolbar) => ({ toolbar }), - (inner) => Message.GotToolbarMessage({ message: inner }), - ), - GotTooltipMessage: ({ message: tooltip }) => { - const result = TooltipHost.update(model.tooltip, tooltip, (inner) => + GotTooltipMessage: ({ message: tooltipMessage }) => { + const result = TooltipHost.update(activeTooltipOf(model), tooltipMessage, (inner) => Message.GotTooltipMessage({ message: inner }), ) - const hoveredTriggerKey = - tooltip.message._tag === "HoveredTrigger" - ? tooltip.key - : tooltip.message._tag === "UnhoveredTrigger" && model.hoveredTriggerKey === tooltip.key - ? null - : model.hoveredTriggerKey - const focusedTriggerKey = - tooltip.message._tag === "FocusedTrigger" - ? tooltip.key - : tooltip.message._tag === "BlurredTrigger" && model.focusedTriggerKey === tooltip.key - ? null - : model.focusedTriggerKey - return result.model === model.tooltip && + const hoveredTrigger = nextTrigger( + model.hoveredTrigger, + tooltipMessage, + "HoveredTrigger", + "UnhoveredTrigger", + ) + const focusedTrigger = nextTrigger( + model.focusedTrigger, + tooltipMessage, + "FocusedTrigger", + "BlurredTrigger", + ) + const isUnchanged = + result.model === activeTooltipOf(model) && (result.commands ?? []).length === 0 && - hoveredTriggerKey === model.hoveredTriggerKey && - focusedTriggerKey === model.focusedTriggerKey - ? { model } - : { - model: { ...model, tooltip: result.model, hoveredTriggerKey, focusedTriggerKey }, - commands: result.commands, - } + isSameTrigger(hoveredTrigger, model.hoveredTrigger) && + isSameTrigger(focusedTrigger, model.focusedTrigger) + if (isUnchanged) return { model } + // The host only moves to a trigger on that trigger's own message, so its id names the owner. + const owner = + result.model?.id === tooltipMessage.key ? tooltipMessage.messageId : model.tooltip?.messageId + return { + model: modifyFields(model, { + tooltip: () => + result.model === null || owner === undefined + ? null + : { messageId: owner, tooltip: result.model }, + hoveredTrigger: () => + isSameTrigger(hoveredTrigger, model.hoveredTrigger) + ? model.hoveredTrigger + : hoveredTrigger, + focusedTrigger: () => + isSameTrigger(focusedTrigger, model.focusedTrigger) + ? model.focusedTrigger + : focusedTrigger, + }), + commands: result.commands, + } }, GotMoreMenuMessage: ({ messageId, message: menuMessage }) => { const result = Menu.update(moreMenuFor(model, messageId, facts), menuMessage) return withSelection( - mapped( - model, - result, - (menu) => ({ moreMenu: { messageId, menu } }), - (inner) => Message.GotMoreMenuMessage({ messageId, message: inner }), - ), + withChild(model, result, { + isCurrent: model.open._tag === "MoreMenu" && model.open.messageId === messageId, + isOpen: isMenuOpen, + toOpen: (menu) => OpenOverlay.MoreMenu({ messageId, menu }), + wrap: (inner) => Message.GotMoreMenuMessage({ messageId, message: inner }), + }), messageId, result.outMessage, ) @@ -297,47 +421,54 @@ export const update = (model: Model, message: Message, facts: MessageFacts): Ove anchor: "Pointer", entries: contextMenuEntries(facts, messageId), }) - return mapped( + return withChild( model, Menu.update(menu, Menu.Message.PressedContextMenu({ offset, crossOffset })), - (opened) => ({ contextMenu: { messageId, menu: opened } }), - (inner) => Message.GotContextMenuMessage({ message: inner }), + { + isCurrent: true, + isOpen: isMenuOpen, + toOpen: (opened) => OpenOverlay.ContextMenu({ messageId, menu: opened }), + wrap: (inner) => Message.GotContextMenuMessage({ message: inner }), + }, ) }, GotContextMenuMessage: ({ message: menuMessage }) => { - const current = model.contextMenu + const current = contextMenuOf(model) if (current === null) return { model } const result = Menu.update(current.menu, menuMessage) return withSelection( - mapped( - model, - result, - (menu) => ({ contextMenu: { messageId: current.messageId, menu } }), - (inner) => Message.GotContextMenuMessage({ message: inner }), - ), + withChild(model, result, { + isCurrent: true, + isOpen: isMenuOpen, + toOpen: (menu) => OpenOverlay.ContextMenu({ messageId: current.messageId, menu }), + wrap: (inner) => Message.GotContextMenuMessage({ message: inner }), + }), current.messageId, result.outMessage, ) }, - GotPopoverMessage: ({ popoverId: key, message: popoverMessage }) => { + GotPopoverMessage: ({ messageId, message: popoverMessage }) => { + const isCurrent = model.open._tag === "Popover" && model.open.messageId === messageId const current = - model.popover !== null && model.popover.key === key - ? model.popover.popover - : Popover.init(key) - return mapped( - model, - Popover.update(current, popoverMessage), - (popover) => ({ popover: { key, popover } }), - (inner) => Message.GotPopoverMessage({ popoverId: key, message: inner }), - ) + model.open._tag === "Popover" && isCurrent + ? model.open.popover + : Popover.init(avatarPopoverKey(messageId)) + return withChild(model, Popover.update(current, popoverMessage), { + isCurrent, + isOpen: (popover) => popover.isOpen, + toOpen: (popover) => OpenOverlay.Popover({ messageId, popover }), + wrap: (inner) => Message.GotPopoverMessage({ messageId, message: inner }), + }) + }, + GotPinnedMessage: ({ message: popoverMessage }) => { + const result = Popover.update(model.pinned, popoverMessage) + return { + model: modifyFields(model, { pinned: () => result.model }), + commands: Command.mapMessages(result.commands ?? [], (inner) => + Message.GotPinnedMessage({ message: inner }), + ), + } }, - GotPinnedMessage: ({ message: popoverMessage }) => - mapped( - model, - Popover.update(model.pinned, popoverMessage), - (pinned) => ({ pinned }), - (inner) => Message.GotPinnedMessage({ message: inner }), - ), ClickedReply: ({ messageId }) => action(model, messageId, "reply"), ClickedDelete: ({ messageId }) => action(model, messageId, "delete"), ClickedCopy: ({ messageId }) => action(model, messageId, "copy"), @@ -346,31 +477,56 @@ export const update = (model: Model, message: Message, facts: MessageFacts): Ove model, outMessage: OutMessage.RequestedReaction({ messageId, emoji }), }), - ClickedAttachmentImage: ({ messageId, index }) => - set(model, { imageViewer: { messageId, index, urlImages: null } }), - ClickedEmbedImage: ({ messageId, images, index }) => - set(model, { imageViewer: { messageId, index, urlImages: images } }), - ClosedImageViewer: () => set(model, { imageViewer: null }), - SelectedViewerImage: ({ index }) => - model.imageViewer === null + ClickedAttachmentImage: ({ messageId, index }) => ({ + model: modifyFields(model, { + open: () => OpenOverlay.ImageViewer({ messageId, index, urlImages: null }), + }), + }), + ClickedEmbedImage: ({ messageId, images, index }) => ({ + model: modifyFields(model, { + open: () => OpenOverlay.ImageViewer({ messageId, index, urlImages: images }), + }), + }), + ClosedImageViewer: () => + model.open._tag === "ImageViewer" + ? { model: modifyFields(model, { open: () => OpenOverlay.None() }) } + : { model }, + SelectedViewerImage: ({ index }) => { + const viewer = imageViewerOf(model) + return viewer === null ? { model } - : set(model, { imageViewer: { ...model.imageViewer, index } }), + : { + model: modifyFields(model, { + open: () => + OpenOverlay.ImageViewer({ + messageId: viewer.messageId, + urlImages: viewer.urlImages, + index, + }), + }), + } + }, ClickedViewerAction: ({ action, url, fileName }) => - model.imageViewer === null - ? { model } - : { model, outMessage: OutMessage.RequestedViewerAction({ action, url, fileName }) }, + model.open._tag === "ImageViewer" + ? { model, outMessage: OutMessage.RequestedViewerAction({ action, url, fileName }) } + : { model }, GotReactionPickerMessage: ({ messageId, message: dialogMessage }) => { - const current = - model.reactionPicker?.messageId === messageId - ? model.reactionPicker.dialog - : EmojiDialog.init(`reaction-picker-${messageId}`) - const result = EmojiDialog.update(current, dialogMessage) - const next = mapped( - model, - result, - (dialog) => ({ reactionPicker: dialog.isOpen ? { messageId, dialog } : null }), - (inner) => Message.GotReactionPickerMessage({ messageId, message: inner }), - ) + const isCurrent = model.open._tag === "ReactionPicker" && model.open.messageId === messageId + const result = EmojiDialog.update(reactionPickerFor(model, messageId), dialogMessage) + const next = { + model: + isCurrent || result.model.isOpen + ? modifyFields(model, { + open: () => + result.model.isOpen + ? OpenOverlay.ReactionPicker({ messageId, dialog: result.model }) + : OpenOverlay.None(), + }) + : model, + commands: Command.mapMessages(result.commands ?? [], (inner) => + Message.GotReactionPickerMessage({ messageId, message: inner }), + ), + } // `handleReaction` with the picked emoji's string. return result.outMessage === undefined ? next @@ -383,92 +539,88 @@ export const update = (model: Model, message: Message, facts: MessageFacts): Ove } }, GotReactionModalMessage: ({ message: modalMessage }) => { - const current = model.reactionModal + const current = reactionModalOf(model) if (current === null) return { model } const result = Modal.update(current.modal, modalMessage) - return mapped( - model, - result, - (modal) => ({ reactionModal: modal.isOpen ? { ...current, modal } : null }), - (inner) => Message.GotReactionModalMessage({ message: inner }), - ) + return { + model: modifyFields(model, { + open: () => + result.model.isOpen + ? OpenOverlay.ReactionModal({ ...current, modal: result.model }) + : OpenOverlay.None(), + }), + commands: Command.mapMessages(result.commands ?? [], (inner) => + Message.GotReactionModalMessage({ message: inner }), + ), + } }, GotReactionModalPickerMessage: ({ message: pickerMessage }) => { - const current = model.reactionModal + const current = reactionModalOf(model) if (current === null) return { model } const result = Picker.update(current.picker, pickerMessage) - const next = mapped( - model, - result, - (picker) => ({ reactionModal: { ...current, picker } }), - (inner) => Message.GotReactionModalPickerMessage({ message: inner }), + const commands = Command.mapMessages(result.commands ?? [], (inner) => + Message.GotReactionModalPickerMessage({ message: inner }), ) // `handleReaction(emoji)` then `modal.close()`. return result.outMessage === undefined - ? next + ? { + model: modifyFields(model, { + open: () => OpenOverlay.ReactionModal({ ...current, picker: result.model }), + }), + commands, + } : { - ...next, - model: { ...next.model, reactionModal: null }, + model: modifyFields(model, { open: () => OpenOverlay.None() }), + commands, outMessage: OutMessage.RequestedReaction({ messageId: current.messageId, emoji: result.outMessage.emoji, }), } }, - ClickedThreadPreview: ({ threadChannelId, messageId }) => ({ - model: openThread(model, threadChannelId, messageId), - }), - ClosedThread: () => set(model, { thread: null }), }) -/** The message id a tooltip or popover key belongs to (keys are `<messageId>:<part>`). */ -const messageIdOfKey = (key: string) => key.split(":")[0] - /** Whether an open overlay renders inside this message's row (so the row cannot reuse its memo). */ export const ownsRow = (model: Model, messageId: MessageId): boolean => - (model.tooltip !== null && messageIdOfKey(model.tooltip.id) === messageId) || - (model.hoveredTriggerKey !== null && messageIdOfKey(model.hoveredTriggerKey) === messageId) || - (model.contextMenu !== null && model.contextMenu.messageId === messageId) || - (model.popover !== null && messageIdOfKey(model.popover.key) === messageId) + model.tooltip?.messageId === messageId || + model.hoveredTrigger?.messageId === messageId || + (model.open._tag === "ContextMenu" && model.open.messageId === messageId) || + (model.open._tag === "Popover" && model.open.messageId === messageId) /** * Rows left the window (deleted, or the window slid): a viewer on a gone message closes, and a * toolbar on a gone message hides (the open viewer kept `PointerLeftList` from hiding it). */ export const forgetMissingMessages = (model: Model, isShown: (messageId: MessageId) => boolean): Model => { - const isViewerGone = model.imageViewer !== null && !isShown(model.imageViewer.messageId) - const isHoverGone = model.hoveredMessageId !== null && !isShown(model.hoveredMessageId) + const isViewerGone = model.open._tag === "ImageViewer" && !isShown(model.open.messageId) + const hovered = hoveredMessageIdOf(model) + const isHoverGone = hovered !== null && !isShown(hovered) if (!isViewerGone && !isHoverGone) return model - return { - ...model, - imageViewer: isViewerGone ? null : model.imageViewer, - hoveredMessageId: isHoverGone ? null : model.hoveredMessageId, - hoverVersion: isHoverGone ? model.hoverVersion + 1 : model.hoverVersion, - isHidePending: isHoverGone ? false : model.isHidePending, - } + return modifyFields(model, { + open: (open) => (isViewerGone ? OpenOverlay.None() : open), + hover: (hover) => (isHoverGone ? Hover.None() : hover), + }) } -/** The thread panel opens on a thread (a preview click, or a thread created from a message). */ -export const openThread = (model: Model, threadChannelId: ChannelId, messageId: MessageId): Model => - model.thread?.threadChannelId === threadChannelId && model.thread.messageId === messageId - ? model - : { ...model, thread: { threadChannelId, messageId } } - -/** The thread panel closes if it shows this thread (its creation failed). */ -export const closeThread = (model: Model, threadChannelId: ChannelId): Model => - model.thread?.threadChannelId === threadChannelId ? { ...model, thread: null } : model - /** The context menu's "Add Reaction" opens the picker modal, which loads its data. */ -export const openReactionModal = (model: Model, messageId: MessageId): OverlaysReturn => ({ - model: { - ...model, - reactionModal: { - messageId, - modal: { id: "reaction-picker-modal", isOpen: true }, - picker: Picker.init("reaction-picker-modal-picker"), - }, - }, - commands: Command.mapMessages([Picker.LoadEmojiData()], (message) => - Message.GotReactionModalPickerMessage({ message }), - ), -}) +export const openReactionModal = (model: Model, messageId: MessageId): OverlaysReturn => { + const opened = Modal.open(Modal.init("reaction-picker-modal")) + return { + model: modifyFields(model, { + open: () => + OpenOverlay.ReactionModal({ + messageId, + modal: opened.model, + picker: Picker.init("reaction-picker-modal-picker"), + }), + }), + commands: [ + ...Command.mapMessages(opened.commands ?? [], (message) => + Message.GotReactionModalMessage({ message }), + ), + ...Command.mapMessages([Picker.LoadEmojiData()], (message) => + Message.GotReactionModalPickerMessage({ message }), + ), + ], + } +} diff --git a/apps/web-foldkit/src/page/chat/row-context.ts b/apps/web-foldkit/src/page/chat/row-context.ts index eb0f7310c..516661cfe 100644 --- a/apps/web-foldkit/src/page/chat/row-context.ts +++ b/apps/web-foldkit/src/page/chat/row-context.ts @@ -1,3 +1,4 @@ +import type { ChannelId, MessageId } from "@hazel/schema" import type { Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { menuHeaderBase } from "~/components/ui/menu.styles" @@ -16,24 +17,49 @@ import { avatarButton, type RowContext } from "../../chat/message/row" import { TOP_EMOJIS } from "../../chat/message/toolbar" import { popoverTrigger } from "../../chat/popover-host" import { profilePopoverContent } from "../../chat/profile-popover" +import type * as TooltipHost from "../../chat/tooltip-host" import { button } from "../../ui/button" +import type * as Menu from "../../ui/menu" import { contextMenuView, menuLabel } from "../../ui/menu-view" +import type * as Popover from "../../ui/popover" import * as Overlays from "./overlays" import { factsOf, Message, type Model } from "./channel/page" import type { MessageRow } from "./rows" /** * The context message rows render with. Rows are memoized by argument identity, so every row - * without an open overlay shares one cached context; only the row owning an overlay gets its own. + * without an open overlay shares one context per user list; only the row owning an overlay gets its own. */ -type ToParent<M> = (message: Message) => M +type H = HtmlBuilder<Message> -const overlaysMessage = <M>(toParentMessage: ToParent<M>, message: Overlays.Message) => - toParentMessage(Message.GotOverlaysMessage({ message })) +// The page's mappers, built once: a row context made of them compares equal across renders. + +const toOverlays = (message: Overlays.Message) => Message.GotOverlaysMessage({ message }) + +const toTooltipMessage = (message: TooltipHost.Message) => + toOverlays(Overlays.Message.GotTooltipMessage({ message })) + +const toOpenImage = (messageId: MessageId, index: number) => + toOverlays(Overlays.Message.ClickedAttachmentImage({ messageId, index })) + +const toOpenEmbedImage = ( + messageId: MessageId, + images: ReadonlyArray<{ readonly url: string; readonly alt: string }>, + index: number, +) => toOverlays(Overlays.Message.ClickedEmbedImage({ messageId, images, index })) + +const toReact = (messageId: MessageId, emoji: string) => + toOverlays(Overlays.Message.ClickedReaction({ messageId, emoji })) + +const toOpenThread = (threadChannelId: ChannelId, messageId: MessageId) => + Message.ClickedThreadPreview({ threadChannelId, messageId }) + +const toContextMenuMessage = (message: Menu.Message) => + toOverlays(Overlays.Message.GotContextMenuMessage({ message })) /** `ContextMenuHeader` with the quick reactions row. */ -const contextMenuHeader = <M>(h: HtmlBuilder<M>, toReact: (emoji: string) => M): Html => +const contextMenuHeader = (h: H, messageId: MessageId): Html => h.header( [h.Class(twMerge(menuHeaderBase, false, "flex items-center gap-1 px-1 py-1"))], TOP_EMOJIS.map((emoji) => @@ -43,7 +69,7 @@ const contextMenuHeader = <M>(h: HtmlBuilder<M>, toReact: (emoji: string) => M): size: "sq-md", intent: "plain", className: "p-1! text-lg hover:bg-secondary", - onPress: toReact(emoji), + onPress: toReact(messageId, emoji), attributes: [h.Attribute("aria-label", `React with ${emoji}`)], }, [emoji], @@ -51,13 +77,13 @@ const contextMenuHeader = <M>(h: HtmlBuilder<M>, toReact: (emoji: string) => M): ), ) -const contextItem = <M>(h: HtmlBuilder<M>, key: string, label: string, icon: Html): ReadonlyArray<Html> => [ +const contextItem = (h: H, key: string, label: string, icon: Html): ReadonlyArray<Html> => [ menuLabel(h, "message-context-menu", key, label), icon, ] const contextMenuContent = - <M>(h: HtmlBuilder<M>, isPinned: boolean) => + (h: H, isPinned: boolean) => (key: string): ReadonlyArray<Html> => { const icon = (render: typeof IconCopy, className = "ml-auto size-4 text-muted-fg") => render(h, { className, attributes: { "data-slot": "icon" } }) @@ -77,65 +103,82 @@ const contextMenuContent = return contextItem(h, key, "Copy Message ID", icon(IconHashtag)) } -const buildContext = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: ToParent<M>, - isIdle: boolean, - nowMs: number, -): RowContext<M> => { +const userNamesOf = (users: Model["lookups"]["users"]) => + new Map(users.map((user) => [user.id as string, `${user.firstName} ${user.lastName}`])) + +/** The context of a row without an open overlay: no tooltip, menu or popover is ever open in it. */ +const buildIdleContext = ( + h: H, + users: Model["lookups"]["users"], + currentUserId: Model["currentUserId"], +): RowContext<Message> => { + const names = userNamesOf(users) + return { + tooltip: { + active: null, + hoveredKey: null, + toMessage: toTooltipMessage, + userName: (userId) => names.get(userId) ?? null, + currentUserId, + }, + toOpenImage, + toOpenEmbedImage, + toReact, + toOpenThread, + contextMenu: (_row, render) => render([], h.empty), + avatar: (row: MessageRow) => + popoverTrigger(h, { + key: Overlays.avatarPopoverKey(row.message.id), + active: null, + toMessage: (_key, message: Popover.Message) => + toOverlays(Overlays.Message.GotPopoverMessage({ messageId: row.message.id, message })), + placement: "right top", + className: "w-72 p-0 lg:w-80", + toTrigger: (attributes, overlay) => avatarButton(h, row, attributes, overlay), + toContent: () => [], + }), + } +} + +/** The context of the row an overlay belongs to: the open tooltip, context menu or profile popover. */ +const buildOwnerContext = (h: H, model: Model, nowMs: number): RowContext<Message> => { const users = model.lookups.users - const names = new Map(users.map((user) => [user.id as string, `${user.firstName} ${user.lastName}`])) + const names = userNamesOf(users) const overlays = model.overlays - const toPopoverMessage = ( - key: string, - message: Parameters<typeof Overlays.Message.GotPopoverMessage>[0]["message"], - ) => overlaysMessage(toParentMessage, Overlays.Message.GotPopoverMessage({ popoverId: key, message })) + const contextMenu = Overlays.contextMenuOf(overlays) return { tooltip: { - active: isIdle ? null : overlays.tooltip, - hoveredKey: isIdle ? null : overlays.hoveredTriggerKey, - toMessage: (message) => - overlaysMessage(toParentMessage, Overlays.Message.GotTooltipMessage({ message })), + active: Overlays.activeTooltipOf(overlays), + hoveredKey: overlays.hoveredTrigger?.key ?? null, + toMessage: toTooltipMessage, userName: (userId) => names.get(userId) ?? null, currentUserId: model.currentUserId, }, - toOpenImage: (messageId, index) => - overlaysMessage(toParentMessage, Overlays.Message.ClickedAttachmentImage({ messageId, index })), - toOpenEmbedImage: (messageId, images, index) => - overlaysMessage(toParentMessage, Overlays.Message.ClickedEmbedImage({ messageId, images, index })), - toReact: (messageId, emoji) => - overlaysMessage(toParentMessage, Overlays.Message.ClickedReaction({ messageId, emoji })), - toOpenThread: (threadChannelId, messageId) => - overlaysMessage( - toParentMessage, - Overlays.Message.ClickedThreadPreview({ threadChannelId, messageId }), - ), - contextMenu: (row, render) => { - const open = isIdle ? null : overlays.contextMenu - if (open === null || open.messageId !== row.message.id) return render([], h.empty) - return h.submodel({ - slotId: "message-context-menu", - model: open.menu, - view: contextMenuView, - viewInputs: { - toTrigger: render, - content: contextMenuContent(h, factsOf(model).isPinned(row.message.id)), - className: "min-w-56", - header: () => - contextMenuHeader(h, (emoji) => - overlaysMessage(toParentMessage, Overlays.Message.ClickedReaction({ messageId: row.message.id, emoji })), - ), - }, - toParentMessage: (message) => - overlaysMessage(toParentMessage, Overlays.Message.GotContextMenuMessage({ message })), - }) - }, + toOpenImage, + toOpenEmbedImage, + toReact, + toOpenThread, + contextMenu: (row, render) => + contextMenu === null || contextMenu.messageId !== row.message.id + ? render([], h.empty) + : h.submodel({ + slotId: "message-context-menu", + model: contextMenu.menu, + view: contextMenuView, + viewInputs: { + toTrigger: render, + content: contextMenuContent(h, factsOf(model).isPinned(row.message.id)), + className: "min-w-56", + header: () => contextMenuHeader(h, row.message.id), + }, + toParentMessage: toContextMenuMessage, + }), avatar: (row: MessageRow) => popoverTrigger(h, { - key: `${row.message.id}:avatar`, - active: isIdle ? null : overlays.popover, - toMessage: toPopoverMessage, + key: Overlays.avatarPopoverKey(row.message.id), + active: Overlays.activePopoverOf(overlays), + toMessage: (_key, message: Popover.Message) => + toOverlays(Overlays.Message.GotPopoverMessage({ messageId: row.message.id, message })), placement: "right top", className: "w-72 p-0 lg:w-80", toTrigger: (attributes, overlay) => avatarButton(h, row, attributes, overlay), @@ -157,50 +200,31 @@ const buildContext = <M>( } } -interface Cache<M> { - readonly toParentMessage: ToParent<M> - readonly users: Model["lookups"]["users"] - readonly currentUserId: Model["currentUserId"] - readonly context: RowContext<M> -} - -// A view memo, not state: one shared context for idle rows keeps the row lazies hitting (see view.ts memoRow). -// oxlint-disable-next-line foldkit/no-module-level-mutable-state -let idleCache: Cache<never> | null = null +// A pure view memo keyed by the (frozen) user list, like `MessageList.layoutOf`: the same Model +// always yields the same idle context, so rendering it twice gives the same result. +const idleContexts = new WeakMap< + Model["lookups"]["users"], + Map<Model["currentUserId"], RowContext<Message>> +>() /** The shared context for rows without an open overlay. */ -export const idleRowContext = <M>( - h: HtmlBuilder<M>, - model: Model, - toParentMessage: ToParent<M>, -): RowContext<M> => { - const cache: Cache<M> | null = idleCache - if ( - cache !== null && - cache.toParentMessage === toParentMessage && - cache.users === model.lookups.users && - cache.currentUserId === model.currentUserId - ) - return cache.context - // Idle rows never open the profile popover, so they need no clock. - const context = buildContext(h, model, toParentMessage, true, 0) - const next: Cache<M> = { - toParentMessage, - users: model.lookups.users, - currentUserId: model.currentUserId, - context, - } - idleCache = next as Cache<never> +export const idleRowContext = (h: H, model: Model): RowContext<Message> => { + const byUser = + idleContexts.get(model.lookups.users) ?? new Map<Model["currentUserId"], RowContext<Message>>() + const cached = byUser.get(model.currentUserId) + if (cached !== undefined) return cached + const context = buildIdleContext(h, model.lookups.users, model.currentUserId) + byUser.set(model.currentUserId, context) + idleContexts.set(model.lookups.users, byUser) return context } /** The context for one row: the idle one, unless an overlay belongs to this message. */ -export const rowContextFor = <M>( - h: HtmlBuilder<M>, +export const rowContextFor = ( + h: H, model: Model, row: MessageRow, - toParentMessage: ToParent<M>, - idle: RowContext<M>, + idle: RowContext<Message>, nowMs: number, -): RowContext<M> => - Overlays.ownsRow(model.overlays, row.message.id) ? buildContext(h, model, toParentMessage, false, nowMs) : idle +): RowContext<Message> => + Overlays.ownsRow(model.overlays, row.message.id) ? buildOwnerContext(h, model, nowMs) : idle diff --git a/apps/web-foldkit/src/page/chat/rows.ts b/apps/web-foldkit/src/page/chat/rows.ts index c4f61dc6f..6064d34ff 100644 --- a/apps/web-foldkit/src/page/chat/rows.ts +++ b/apps/web-foldkit/src/page/chat/rows.ts @@ -135,23 +135,8 @@ export const DisplayRow = defineTaggedUnion({ export type DisplayRow = typeof DisplayRow.Type export type MessageRow = Extract<DisplayRow, { readonly _tag: "MessageRow" }> -/** Structural equality for row data (small objects), used to keep unchanged rows' identity. */ -export const deepEqual = (a: unknown, b: unknown): boolean => { - if (a === b) return true - if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false - if (Array.isArray(a)) { - if (!Array.isArray(b) || a.length !== b.length) return false - return a.every((item, index) => deepEqual(item, b[index])) - } - if (Array.isArray(b)) return false - const aKeys = Object.keys(a) - const bRecord = b as Record<string, unknown> - const aRecord = a as Record<string, unknown> - return ( - aKeys.length === Object.keys(b).length && - aKeys.every((key) => key in bRecord && deepEqual(aRecord[key], bRecord[key])) - ) -} +/** Field equality of two messages, used to keep unchanged messages' identity. */ +const isSameMessage = Schema.toEquivalence(ChatMessage) /** Reuses the previous object for every message whose fields did not change. */ export const shareMessages = ( @@ -161,7 +146,7 @@ export const shareMessages = ( const previousById = new Map(previous.map((message) => [message.id, message])) return next.map((message) => { const old = previousById.get(message.id) - return old !== undefined && deepEqual(old, message) ? old : message + return old !== undefined && isSameMessage(old, message) ? old : message }) } @@ -174,7 +159,7 @@ export const applyMessageChanges = ( const byId = new Map(previous.map((message) => [message.id, message])) for (const message of upserts) { const old = byId.get(message.id) - byId.set(message.id, old !== undefined && deepEqual(old, message) ? old : message) + byId.set(message.id, old !== undefined && isSameMessage(old, message) ? old : message) } const next = order.flatMap((id) => { const message = byId.get(id) diff --git a/apps/web-foldkit/src/page/chat/thread-panel.ts b/apps/web-foldkit/src/page/chat/thread-panel.ts index 4f9c6162c..c6f494d59 100644 --- a/apps/web-foldkit/src/page/chat/thread-panel.ts +++ b/apps/web-foldkit/src/page/chat/thread-panel.ts @@ -7,35 +7,18 @@ import { IconClose, IconEdit, IconPenSparkle } from "../../icons" import { avatar } from "../../ui/avatar" import { button } from "../../ui/button" import { loader } from "../../ui/loader" -import { type DeriveContext, authorIdentity, messageRowData } from "./derive" -import type { ChatMessage, GroupPosition, MessageRow } from "./rows" +import { type DeriveContext, authorIdentity } from "./derive" +import type { ChatMessage, MessageRow } from "./rows" /** Port of `SplitPanel` + `ThreadPanel` (header, original message, `ThreadMessageList`, composer). */ -const GROUP_THRESHOLD_MS = 3 * 60 * 1000 const PANEL_WIDTH = 480 -/** `ThreadMessageListContent`'s grouping (oldest first). */ -const groupPositionAt = (messages: ReadonlyArray<ChatMessage>, index: number): GroupPosition => { - const message = messages[index]! - const previous = index > 0 ? messages[index - 1]! : null - const next = index < messages.length - 1 ? messages[index + 1]! : null - const isStart = - previous === null || - message.authorId !== previous.authorId || - message.createdAtMs - previous.createdAtMs > GROUP_THRESHOLD_MS || - previous.replyToMessageId !== null - const isEnd = - next === null || - message.authorId !== next.authorId || - next.createdAtMs - message.createdAtMs > GROUP_THRESHOLD_MS - return isStart && isEnd ? "standalone" : isStart ? "start" : isEnd ? "end" : "middle" -} - export interface ThreadPanelInputs<M> { readonly threadName: string readonly original: ChatMessage | null - readonly messages: ReadonlyArray<ChatMessage> + /** The replies' rows, derived in `update` (`toThreadRows`). */ + readonly rows: ReadonlyArray<MessageRow> readonly context: DeriveContext readonly rowContext: RowContext<M> readonly onClose: M @@ -48,7 +31,13 @@ export interface ThreadPanelInputs<M> { readonly composer: Html } -const headerButton = <M>(h: HtmlBuilder<M>, label: string, icon: Html, onPress: M, isDisabled = false): Html => +const headerButton = <M>( + h: HtmlBuilder<M>, + label: string, + icon: Html, + onPress: M, + isDisabled = false, +): Html => button( h, { @@ -99,26 +88,21 @@ const originalView = <M>(h: HtmlBuilder<M>, original: ChatMessage, context: Deri } const listView = <M>(h: HtmlBuilder<M>, inputs: ThreadPanelInputs<M>): Html => - inputs.messages.length === 0 + inputs.rows.length === 0 ? h.div( [h.Class("flex h-full items-center justify-center p-4")], [h.p([h.Class("text-muted-fg")], ["No replies yet. Start the conversation!"])], ) : h.div( [h.Class("flex h-full flex-col overflow-y-auto px-2 py-2")], - inputs.messages.map((message, index) => { - const row: MessageRow = { - _tag: "MessageRow", - ...messageRowData( - message, - groupPositionAt(inputs.messages, index), - [], - inputs.context, - ), - thread: null, - } - return messageRowView(h, row, inputs.rowContext) - }), + // Keyed by message, so a reply arriving or deleted never re-renders the rows after it. + inputs.rows.map((row) => + h.keyed("div")( + row.key, + [h.Style({ display: "contents" })], + [messageRowView(h, row, inputs.rowContext)], + ), + ), ) export const threadPanelView = <M>(h: HtmlBuilder<M>, inputs: ThreadPanelInputs<M>): Html => @@ -206,7 +190,11 @@ export const threadPanelView = <M>(h: HtmlBuilder<M>, inputs: ThreadPanelInputs< [ inputs.isCreating ? h.div( - [h.Class("flex items-center justify-center gap-2 py-3 text-muted-fg text-sm")], + [ + h.Class( + "flex items-center justify-center gap-2 py-3 text-muted-fg text-sm", + ), + ], [loader(h, { className: "size-4" }), "Creating thread..."], ) : inputs.composer, diff --git a/apps/web-foldkit/src/test/chat-fixtures.ts b/apps/web-foldkit/src/test/chat-fixtures.ts index 24ea7398e..7b2192f80 100644 --- a/apps/web-foldkit/src/test/chat-fixtures.ts +++ b/apps/web-foldkit/src/test/chat-fixtures.ts @@ -8,7 +8,12 @@ import * as Overlays from "../page/chat/overlays" import * as Tooltip from "../ui/tooltip" import * as Menu from "../ui/menu" import { Message, type Model } from "../page/chat/channel/model" -import { loadedModel, messages, updateWithShared } from "../page/chat/channel/fixtures.test-support" +import { + loadedMessages, + loadedModel, + messages, + updateWithShared, +} from "../page/chat/channel/fixtures.test-support" import { view } from "../page/chat/channel/view" import type { MessageId } from "@hazel/schema" @@ -16,11 +21,9 @@ import type { MessageId } from "@hazel/schema" type Step = (simulation: Scene.SceneSimulation<Model, Message>) => Scene.SceneSimulation<Model, Message> -const toSelf = (message: Message) => message - export const channelScene = { update: updateWithShared, - view: (model: Model, h: HtmlBuilder<Message>): Html => view(h, model, toSelf), + view: (model: Model, h: HtmlBuilder<Message>): Html => view(h, model), } export const VIEWPORT_PX = 600 @@ -28,12 +31,11 @@ export const VIEWPORT_PX = 600 export const list = (message: MessageList.Message) => Message.GotListMessage({ message }) export const overlays = (message: Overlays.Message) => Message.GotOverlaysMessage({ message }) export const draft = (message: Draft.Message) => Message.GotDraftMessage({ message }) -export const composer = (message: Composer.Message) => - draft(Draft.Message.GotComposerMessage({ message })) +export const composer = (message: Composer.Message) => draft(Draft.Message.GotComposerMessage({ message })) /** `loadedModel` with its rows derived by update, as the messages query delivers them. */ export const derivedModel = (base: Model = loadedModel()): Model => - updateWithShared(base, Message.UpdatedMessages({ messages: base.messages.length === 0 ? messages : base.messages })).model + updateWithShared(base, loadedMessages(base.messages.length === 0 ? messages : base.messages)).model /** Runs steps in order (Scene has no `steps` grouping helper). */ export const sequence = @@ -44,11 +46,18 @@ export const sequence = /** The channel's first render: the list measures its viewport, the editor, drop zone and hover tracker start. */ export const mountedChannel = (hovered: MessageId | null = null): Step => Scene.Mount.resolveAll( - [{ name: "ObserveMessageList" }, list(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT_PX }))], + [ + { name: "ObserveMessageList" }, + list(MessageList.Message.ResizedViewport({ viewportHeight: VIEWPORT_PX })), + ], [{ name: "MountEditor" }, composer(Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }))], [ { name: "TrackFileDrop" }, - draft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ isDraggingOnPage: false, isDropTarget: false }) })), + draft( + Draft.Message.ReceivedDropEvent({ + event: DropEvent.ChangedDragState({ isDraggingOnPage: false, isDropTarget: false }), + }), + ), ], [ { name: "TrackMessageHover" }, From 018dcbab36554db6230fb799c04f91239b1a8ffe Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:26:59 +0200 Subject: [PATCH 34/52] fix(web): emoji and GIF pickers hold AsyncData with typed failures and retry Emoji data loads through HttpClient with schema decoding and a retry; a failure shows an error with Try again instead of a permanent spinner. The GIF picker keeps results as AsyncData plus a More union, so a network error is not 'No GIFs found'. GIFs are keyboard-usable buttons, the emoji search has a label, picker keys use an element handler, moveActive is pure and the picker data cache is bounded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/emoji-picker/data.ts | 218 +++++++++---- apps/web-foldkit/src/emoji-picker/dialog.ts | 65 ++-- apps/web-foldkit/src/emoji-picker/picker.ts | 240 +++++++++----- .../src/emoji-picker/story.test.ts | 108 ++++++- apps/web-foldkit/src/emoji-picker/view.ts | 141 ++++++-- apps/web-foldkit/src/gif-picker/picker.ts | 300 ++++++++++++------ apps/web-foldkit/src/gif-picker/story.test.ts | 153 ++++++++- apps/web-foldkit/src/gif-picker/view.ts | 267 ++++++++++------ .../src/overlay/modal/set-status-view.ts | 60 +++- .../web-foldkit/src/picker-popover/popover.ts | 6 +- 10 files changed, 1155 insertions(+), 403 deletions(-) diff --git a/apps/web-foldkit/src/emoji-picker/data.ts b/apps/web-foldkit/src/emoji-picker/data.ts index 162397273..556fb88ce 100644 --- a/apps/web-foldkit/src/emoji-picker/data.ts +++ b/apps/web-foldkit/src/emoji-picker/data.ts @@ -1,4 +1,6 @@ -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, Headers, HttpClient, type HttpClientError, HttpClientResponse } from "effect/http" +import { isRetryableError } from "~/lib/error-messages" /** * frimousse's emoji data (`data/emoji.ts`, `data/emoji-picker.ts`): the emojibase dataset from @@ -16,6 +18,7 @@ const SkinTones = Schema.Struct({ "medium-dark": Schema.String, dark: Schema.String, }) +type SkinTones = typeof SkinTones.Type export const EmojiDataEmoji = Schema.Struct({ emoji: Schema.String, @@ -38,7 +41,10 @@ export type EmojiData = typeof EmojiData.Type const LocalData = Schema.Struct({ data: EmojiData, - metadata: Schema.Struct({ emojisEtag: Schema.NullOr(Schema.String), messagesEtag: Schema.NullOr(Schema.String) }), + metadata: Schema.Struct({ + emojisEtag: Schema.NullOr(Schema.String), + messagesEtag: Schema.NullOr(Schema.String), + }), }) const SessionMetadata = Schema.Struct({ emojiVersion: Schema.Number, countryFlags: Schema.Boolean }) @@ -54,78 +60,137 @@ const readStorage = <A>(storage: Storage, key: string, schema: Schema.Codec<A>) Option.flatMap(Schema.decodeUnknownOption(schema)), ) +/** A best-effort cache write: a full or disabled storage only costs the next load a download. */ +const writeStorage = (storage: Storage, key: string, value: unknown) => + Effect.try(() => storage.setItem(key, JSON.stringify(value))).pipe(Effect.ignore) + const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1) -interface EmojibaseEmoji { - readonly emoji: string - readonly label: string - readonly version: number - readonly group?: number - readonly subgroup?: number - readonly tags?: ReadonlyArray<string> - readonly skins?: ReadonlyArray<{ readonly emoji: string; readonly tone?: number | ReadonlyArray<number> }> -} +/** emojibase's `data.json` entries, as far as the picker reads them. */ +const EmojibaseEmoji = Schema.Struct({ + emoji: Schema.String, + label: Schema.String, + version: Schema.Number, + group: Schema.optionalKey(Schema.Number), + subgroup: Schema.optionalKey(Schema.Number), + tags: Schema.optionalKey(Schema.Array(Schema.String)), + skins: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + emoji: Schema.String, + tone: Schema.optionalKey(Schema.Union([Schema.Number, Schema.Array(Schema.Number)])), + }), + ), + ), +}) +type EmojibaseEmoji = typeof EmojibaseEmoji.Type -interface EmojibaseMessages { - readonly groups: ReadonlyArray<{ readonly key: string; readonly message: string; readonly order: number }> - readonly subgroups: ReadonlyArray<{ readonly key: string; readonly order: number }> - readonly skinTones: ReadonlyArray<{ readonly key: string; readonly message: string }> +/** emojibase's `messages.json`, as far as the picker reads it. */ +const EmojibaseMessages = Schema.Struct({ + groups: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String, order: Schema.Number })), + subgroups: Schema.Array(Schema.Struct({ key: Schema.String, order: Schema.Number })), + skinTones: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), +}) +type EmojibaseMessages = typeof EmojibaseMessages.Type + +/** emojibase's tone numbers 1 to 5, in `SkinTones` order. */ +const TONES = ["light", "medium-light", "medium", "medium-dark", "dark"] as const + +/** The five single-tone variants, or none when emojibase lists fewer. */ +const skinsOf = (skins: EmojibaseEmoji["skins"]): Option.Option<SkinTones> => { + const byTone = new Map( + (skins ?? []).flatMap((skin) => + typeof skin.tone === "number" ? [[skin.tone, skin.emoji] as const] : [], + ), + ) + const light = byTone.get(1) + const mediumLight = byTone.get(2) + const medium = byTone.get(3) + const mediumDark = byTone.get(4) + const dark = byTone.get(5) + return light !== undefined && + mediumLight !== undefined && + medium !== undefined && + mediumDark !== undefined && + dark !== undefined + ? Option.some({ light, "medium-light": mediumLight, medium, "medium-dark": mediumDark, dark }) + : Option.none() } -const TONES = ["none", "light", "medium-light", "medium", "medium-dark", "dark"] as const +const skinToneLabels = (messages: EmojibaseMessages): SkinTones => { + const label = (key: (typeof TONES)[number]) => + capitalize(messages.skinTones.find((tone) => tone.key === key)?.message ?? key) + return { + light: label("light"), + "medium-light": label("medium-light"), + medium: label("medium"), + "medium-dark": label("medium-dark"), + dark: label("dark"), + } +} -const fetchJson = <A>(url: string) => - Effect.tryPromise(async () => { - const response = await fetch(url) - return { json: (await response.json()) as A, etag: response.headers.get("etag") } - }) +/** A CDN JSON file, decoded, with its ETag. Fails with `HttpClientError` (transport, non-2xx) or `SchemaError`. */ +const fetchJson = <S extends Schema.Top & { readonly DecodingServices: never }>(url: string, schema: S) => + HttpClient.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => + HttpClientResponse.schemaBodyJson(schema)(response).pipe( + Effect.map((json) => ({ + json, + etag: Option.getOrNull(Headers.get(response.headers, "etag")), + })), + ), + ), + ) /** `fetchEmojiData`: download, reshape and cache the dataset. */ const fetchEmojiData = (locale: string) => Effect.gen(function* () { const [emojis, messages] = yield* Effect.all( [ - fetchJson<ReadonlyArray<EmojibaseEmoji>>(`${BASE_URL}/${locale}/data.json`), - fetchJson<EmojibaseMessages>(`${BASE_URL}/${locale}/messages.json`), + fetchJson(`${BASE_URL}/${locale}/data.json`, Schema.Array(EmojibaseEmoji)), + fetchJson(`${BASE_URL}/${locale}/messages.json`, EmojibaseMessages), ], { concurrency: 2 }, ) - const flags = messages.json.subgroups.find((group) => group.key === "country-flag" || group.key === "subdivision-flag") + const flags = messages.json.subgroups.find( + (group) => group.key === "country-flag" || group.key === "subdivision-flag", + ) const data: EmojiData = { locale, - emojis: emojis.json - .filter((emoji) => emoji.group !== undefined) - .map((emoji) => { - const skins = (emoji.skins ?? []).filter((skin) => typeof skin.tone === "number") - return { - emoji: emoji.emoji, - category: emoji.group ?? 0, - version: emoji.version, - label: capitalize(emoji.label), - tags: [...(emoji.tags ?? [])], - ...(flags && emoji.subgroup === flags.order ? { countryFlag: true } : {}), - ...(skins.length > 0 - ? { - skins: Object.fromEntries( - skins.map((skin) => [TONES[skin.tone as number], skin.emoji]), - ) as EmojiData["skinTones"], - } - : {}), - } - }), + emojis: emojis.json.flatMap((emoji) => + emoji.group === undefined + ? [] + : [ + { + emoji: emoji.emoji, + category: emoji.group, + version: emoji.version, + label: capitalize(emoji.label), + tags: [...(emoji.tags ?? [])], + ...(flags && emoji.subgroup === flags.order ? { countryFlag: true } : {}), + ...Option.match(skinsOf(emoji.skins), { + onNone: () => ({}), + onSome: (skins) => ({ skins }), + }), + }, + ], + ), categories: messages.json.groups .filter((group) => group.key !== "component") .map((group) => ({ index: group.order, label: capitalize(group.message) })), - skinTones: Object.fromEntries( - messages.json.skinTones.map((tone) => [tone.key, capitalize(tone.message)]), - ) as EmojiData["skinTones"], + skinTones: skinToneLabels(messages.json), } - localStorage.setItem( - LOCAL_DATA_KEY(locale), - JSON.stringify({ data, metadata: { emojisEtag: emojis.etag, messagesEtag: messages.etag } }), - ) + yield* writeStorage(localStorage, LOCAL_DATA_KEY(locale), { + data, + metadata: { emojisEtag: emojis.etag, messagesEtag: messages.etag }, + }) return data - }) + }).pipe( + // A dropped connection or a CDN hiccup gets two more tries before the picker shows its error. + Effect.retry({ times: 2, schedule: Schedule.exponential("300 millis"), while: isRetryableError }), + Effect.provide(FetchHttpClient.layer), + ) /** `isEmojiSupported`: a color emoji renders the same in two text colors. */ const isEmojiSupported = (emoji: string): boolean => { @@ -150,26 +215,31 @@ const isEmojiSupported = (emoji: string): boolean => { /** `getSessionMetadata`: the newest emoji version this browser renders. */ const sessionMetadataFor = (emojis: ReadonlyArray<EmojiDataEmoji>): SessionMetadata => { const firstByVersion = new Map<number, string>() - for (const emoji of emojis) if (!firstByVersion.has(emoji.version)) firstByVersion.set(emoji.version, emoji.emoji) + for (const emoji of emojis) + if (!firstByVersion.has(emoji.version)) firstByVersion.set(emoji.version, emoji.emoji) const versions = [...firstByVersion.keys()].sort((a, b) => b - a) const supported = versions.find((version) => isEmojiSupported(firstByVersion.get(version)!)) return { emojiVersion: supported ?? versions[0] ?? 0, countryFlags: isEmojiSupported("🇪🇺") } } /** `getEmojiData`: cached data when present (ETag checks skipped once per session), else the CDN. */ -export const loadEmojiData = (locale = "en"): Effect.Effect<EmojiData, unknown> => +export const loadEmojiData = ( + locale = "en", +): Effect.Effect<EmojiData, HttpClientError.HttpClientError | Schema.SchemaError> => Effect.gen(function* () { const cached = readStorage(localStorage, LOCAL_DATA_KEY(locale), LocalData) const data = Option.isSome(cached) ? cached.value.data : yield* fetchEmojiData(locale) - const metadata = Option.getOrElse(readStorage(sessionStorage, SESSION_METADATA_KEY, SessionMetadata), () => - sessionMetadataFor(data.emojis), + const metadata = Option.getOrElse( + readStorage(sessionStorage, SESSION_METADATA_KEY, SessionMetadata), + () => sessionMetadataFor(data.emojis), ) - sessionStorage.setItem(SESSION_METADATA_KEY, JSON.stringify(metadata)) + yield* writeStorage(sessionStorage, SESSION_METADATA_KEY, metadata) return { ...data, emojis: data.emojis.filter( (emoji) => - emoji.version <= metadata.emojiVersion && (emoji.countryFlag !== true || metadata.countryFlags), + emoji.version <= metadata.emojiVersion && + (emoji.countryFlag !== true || metadata.countryFlags), ), } }) @@ -212,14 +282,7 @@ const searchEmojis = (emojis: ReadonlyArray<EmojiDataEmoji>, search: string) => return scored.sort((a, b) => b.score - a.score).map(({ emoji }) => emoji) } -const cache = new WeakMap<EmojiData, Map<string, PickerData>>() - -/** `getEmojiPickerData`, memoized per dataset and search. */ -export const pickerDataOf = (data: EmojiData, columns: number, search: string): PickerData => { - const bySearch = cache.get(data) ?? new Map<string, PickerData>() - cache.set(data, bySearch) - const hit = bySearch.get(search) - if (hit) return hit +const buildPickerData = (data: EmojiData, columns: number, search: string): PickerData => { const emojis = searchEmojis(data.emojis, search) const byCategory = new Map<number, Array<PickerEmoji>>() for (const emoji of emojis) { @@ -238,12 +301,31 @@ export const pickerDataOf = (data: EmojiData, columns: number, search: string): rows.push({ categoryIndex, emojis: list.slice(index, index + columns) }) categories.push({ label: category.label, rowsCount: rows.length - startRowIndex, startRowIndex }) } - const result = { + return { count: emojis.length, categories, categoriesStartRowIndices: categories.map((category) => category.startRowIndex), rows, } - bySearch.set(search, result) +} + +/** How many searches each dataset remembers: the open pickers' current ones and a few recent ones. */ +const CACHE_SIZE = 8 + +const cache = new WeakMap<EmojiData, Map<string, PickerData>>() + +/** `getEmojiPickerData`, memoized per dataset in a small LRU keyed by columns and search. */ +export const pickerDataOf = (data: EmojiData, columns: number, search: string): PickerData => { + const recent = cache.get(data) ?? new Map<string, PickerData>() + cache.set(data, recent) + const key = `${columns}:${search}` + const hit = recent.get(key) + recent.delete(key) + const result = hit ?? buildPickerData(data, columns, search) + recent.set(key, result) + if (recent.size > CACHE_SIZE) { + const oldest = recent.keys().next() + if (!oldest.done) recent.delete(oldest.value) + } return result } diff --git a/apps/web-foldkit/src/emoji-picker/dialog.ts b/apps/web-foldkit/src/emoji-picker/dialog.ts index 21f78a26e..17b76151f 100644 --- a/apps/web-foldkit/src/emoji-picker/dialog.ts +++ b/apps/web-foldkit/src/emoji-picker/dialog.ts @@ -1,7 +1,8 @@ -import { Schema } from "effect" -import { Command, type Update } from "foldkit" +import { Option, Schema } from "effect" +import { Update } from "foldkit" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" import { cn } from "~/lib/utils" import { pickerPopover, PopoverEvent } from "../picker-popover/popover" import * as Picker from "./picker" @@ -34,14 +35,28 @@ export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage> // UPDATE -const closed = (model: Model): Model => ({ ...model, isOpen: false }) +const closed = (model: Model): Model => modifyFields(model, { isOpen: () => false }) -/** Opening mounts a fresh picker (frimousse remounts with the popover), which loads its data. */ -const open = (model: Model): Return => ({ - model: { ...model, isOpen: true, picker: Picker.init(model.id) }, - commands: Command.mapMessages([Picker.LoadEmojiData()], (message) => Message.GotPickerMessage({ message })), +const pickerFold = { + read: (model: Model) => Option.some(model.picker), + write: (model: Model, picker: Picker.Model): Model => modifyFields(model, { picker: () => picker }), + toParentMessage: (message: Picker.Message) => Message.GotPickerMessage({ message }), +} + +/** `handleEmojiSelect`: report the emoji (forwarded as the dialog's OutMessage), then close. */ +const foldPicker = Update.foldChild({ + update: Picker.update, + ...pickerFold, + toParentOutMessage: (outMessage: Picker.OutMessage): OutMessage => outMessage, + foldOutMessage: () => (model: Model) => ({ model: closed(model) }), }) +/** Opening mounts a fresh picker (frimousse remounts with the popover), which loads its data. */ +const open = (model: Model): Return => + Update.foldChildStep({ update: Picker.loadData, ...pickerFold })( + modifyFields(model, { isOpen: () => true, picker: () => Picker.init(model.id) }), + ) + export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { ReceivedPopoverEvent: ({ event }) => @@ -52,15 +67,7 @@ export const update = (model: Model, message: Message): Return => PressedOutside: () => ({ model: closed(model) }), CompletedPortalPickerPopover: () => ({ model }), }), - GotPickerMessage: ({ message: pickerMessage }) => { - const result = Picker.update(model.picker, pickerMessage) - const next = { ...model, picker: result.model } - const commands = Command.mapMessages(result.commands ?? [], (inner) => Message.GotPickerMessage({ message: inner })) - // `handleEmojiSelect`: report the emoji, then close. - return result.outMessage === undefined - ? { model: next, commands } - : { model: closed(next), commands, outMessage: result.outMessage } - }, + GotPickerMessage: ({ message: pickerMessage }) => foldPicker(model, pickerMessage), }) // VIEW @@ -83,7 +90,10 @@ const customSection = <M>( return h.div( [h.Class("border-border border-t")], [ - h.div([h.Class(CATEGORY_HEADER_CLASS), h.Attribute("data-slot", "emoji-picker-category-header")], ["Custom"]), + h.div( + [h.Class(CATEGORY_HEADER_CLASS), h.Attribute("data-slot", "emoji-picker-category-header")], + ["Custom"], + ), h.div( [h.Class("grid grid-cols-9 gap-0 px-2 pb-2")], filtered.map((emoji) => @@ -91,11 +101,26 @@ const customSection = <M>( emoji.name, [ h.Attribute("type", "button"), - h.OnClick(toMessage(Picker.Message.ClickedCustomEmoji({ name: emoji.name, imageUrl: emoji.imageUrl }))), - h.Class(cn("flex size-10 items-center justify-center rounded-md hover:bg-accent")), + h.OnClick( + toMessage( + Picker.Message.ClickedCustomEmoji({ + name: emoji.name, + imageUrl: emoji.imageUrl, + }), + ), + ), + h.Class( + cn("flex size-10 items-center justify-center rounded-md hover:bg-accent"), + ), h.Attribute("title", `:${emoji.name}:`), ], - [h.img([h.Attribute("src", emoji.imageUrl), h.Attribute("alt", emoji.name), h.Class("size-7 object-contain")])], + [ + h.img([ + h.Attribute("src", emoji.imageUrl), + h.Attribute("alt", emoji.name), + h.Class("size-7 object-contain"), + ]), + ], ), ), ), diff --git a/apps/web-foldkit/src/emoji-picker/picker.ts b/apps/web-foldkit/src/emoji-picker/picker.ts index caa22c438..16bd5c429 100644 --- a/apps/web-foldkit/src/emoji-picker/picker.ts +++ b/apps/web-foldkit/src/emoji-picker/picker.ts @@ -1,7 +1,10 @@ -import { Effect, Match, Queue, Schema, Stream } from "effect" -import { Command, Mount, type Update } from "foldkit" +import { Effect, Match, Option, Queue, Result, Schema, Stream } from "effect" +import { AsyncData, Command, Mount, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" import { formatCustomEmojiKey } from "~/lib/custom-emoji-utils" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" import { EmojiData, loadEmojiData, type PickerData, type PickerEmoji, pickerDataOf } from "./data" /** @@ -15,11 +18,18 @@ const VIEWPORT_OVERSCAN = 2 // MODEL -export const Interaction = Schema.Literals(["none", "keyboard", "pointer"]) +export const Interaction = Schema.Literals(["None", "Keyboard", "Pointer"]) +export type Interaction = typeof Interaction.Type + +/** Loading the dataset fails only for the shared reasons (the CDN is a plain HTTP fetch). */ +export const LoadEmojiDataFailure = CommonReason +export type LoadEmojiDataFailure = typeof LoadEmojiDataFailure.Type + +export const EmojiDataAsync = AsyncData.Schema(EmojiData, LoadEmojiDataFailure) export const Model = Schema.Struct({ id: Schema.String, - data: Schema.NullOr(EmojiData), + data: EmojiDataAsync.schema, search: Schema.String, interaction: Interaction, activeColumnIndex: Schema.Number, @@ -33,11 +43,12 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type +/** A picker with nothing requested yet; `loadData` (or the parent's `LoadEmojiData`) fetches the dataset. */ export const init = (id: string): Model => ({ id, - data: null, + data: AsyncData.Idle(), search: "", - interaction: "none", + interaction: "None", activeColumnIndex: 0, activeRowIndex: 0, isFocusedWithin: false, @@ -52,7 +63,8 @@ export const init = (id: string): Model => ({ export const Message = defineMessageUnion({ SucceededLoadEmojiData: { data: EmojiData }, - FailedLoadEmojiData: {}, + FailedLoadEmojiData: { reason: LoadEmojiDataFailure }, + ClickedRetryLoadEmojiData: {}, UpdatedSearch: { search: Schema.String }, MeasuredPicker: { rowHeight: Schema.Number, @@ -84,22 +96,30 @@ export const LoadEmojiData = Command.define("LoadEmojiData", { messages: [Message.SucceededLoadEmojiData, Message.FailedLoadEmojiData], execute: loadEmojiData().pipe( Effect.map((data) => Message.SucceededLoadEmojiData({ data })), - Effect.catchCause(() => Effect.succeed(Message.FailedLoadEmojiData())), + catchCommon((reason) => Message.FailedLoadEmojiData({ reason })), ), }) // DERIVED export const pickerData = (model: Model): PickerData | null => - model.data === null ? null : pickerDataOf(model.data, COLUMNS, model.search) + Option.match(AsyncData.getData(model.data), { + onNone: () => null, + onSome: (data) => pickerDataOf(data, COLUMNS, model.search), + }) -/** `$isLoading`. */ +/** `$isLoading`: no dataset yet (and no failure to show), or the sizers are not measured. */ export const isLoading = (model: Model) => - model.data === null || model.viewportHeight === null || model.rowHeight === null || model.categoryHeaderHeight === null + AsyncData.matchData(model.data, { + onEmpty: () => true, + onFailure: () => false, + onData: () => + model.viewportHeight === null || model.rowHeight === null || model.categoryHeaderHeight === null, + }) /** `$activeEmoji`. */ export const activeEmoji = (model: Model): PickerEmoji | null => - model.interaction === "none" + model.interaction === "None" ? null : (pickerData(model)?.rows[model.activeRowIndex]?.emojis[model.activeColumnIndex] ?? null) @@ -114,11 +134,14 @@ export const viewportRange = (model: Model, data: PickerData): ViewportRange => if (data.rows.length === 0 || !categoryHeaderHeight || !rowHeight || !viewportHeight) return { startRowIndex: 0, endRowIndex: 0 } const previousHeaders = data.categories.filter( - (category, index) => index * categoryHeaderHeight + category.startRowIndex * rowHeight < model.scrollTop, + (category, index) => + index * categoryHeaderHeight + category.startRowIndex * rowHeight < model.scrollTop, ).length const totalHeight = data.categories.length * categoryHeaderHeight + data.rows.length * rowHeight const startY = Math.min( - model.scrollTop - previousHeaders * categoryHeaderHeight - Math.floor((VIEWPORT_OVERSCAN * rowHeight) / 2), + model.scrollTop - + previousHeaders * categoryHeaderHeight - + Math.floor((VIEWPORT_OVERSCAN * rowHeight) / 2), totalHeight - viewportHeight, ) const endY = startY + viewportHeight + Math.ceil((VIEWPORT_OVERSCAN * rowHeight) / 2) @@ -128,93 +151,168 @@ export const viewportRange = (model: Model, data: PickerData): ViewportRange => } } +/** The keys the picker root handles (frimousse's document keydown while focus is inside). */ +export const isNavigationKey = (key: string) => key.startsWith("Arrow") || key === "Enter" + // UPDATE export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage> -const resetActive = (model: Model): Model => ({ ...model, interaction: "none", activeColumnIndex: 0, activeRowIndex: 0 }) +/** Start (or restart) loading the dataset. */ +export const loadData = (model: Model): Update.Return<Model, Message> => ({ + model: modifyFields(model, { data: () => AsyncData.Loading() }), + commands: [LoadEmojiData()], +}) + +const withActive = ( + model: Model, + interaction: Interaction, + activeRowIndex: number, + activeColumnIndex: number, +): Model => + modifyFields(model, { + interaction: () => interaction, + activeRowIndex: () => activeRowIndex, + activeColumnIndex: () => activeColumnIndex, + }) + +const resetActive = (model: Model): Model => withActive(model, "None", 0, 0) + +interface Position { + readonly row: number + readonly column: number +} /** frimousse's arrow-key movement across rows and columns. */ -const moveActive = (model: Model, key: string): Model => { - const rows = pickerData(model)?.rows ?? [] - if (model.interaction === "none" || rows.length === 0) - return { ...model, interaction: "keyboard", activeColumnIndex: 0, activeRowIndex: 0 } - let column = model.activeColumnIndex - let row = model.activeRowIndex +const movedPosition = ( + rows: ReadonlyArray<PickerData["rows"][number]>, + { row, column }: Position, + key: string, +): Position => { const previous = rows[row - 1] const next = rows[row + 1] - Match.value(key).pipe( - Match.when("ArrowLeft", () => { - if (column > 0) column -= 1 - else if (previous) [row, column] = [row - 1, previous.emojis.length - 1] - }), - Match.when("ArrowRight", () => { - if (column < (rows[row]?.emojis.length ?? 1) - 1) column += 1 - else if (next) [row, column] = [row + 1, 0] - }), - Match.when("ArrowUp", () => { - if (previous) [row, column] = [row - 1, previous.emojis[column] ? column : previous.emojis.length - 1] + const lastColumnOf = (target: PickerData["rows"][number]) => target.emojis.length - 1 + return Match.value(key).pipe( + Match.when("ArrowLeft", (): Position => { + if (column > 0) return { row, column: column - 1 } + return previous ? { row: row - 1, column: lastColumnOf(previous) } : { row, column } }), - Match.when("ArrowDown", () => { - if (next) [row, column] = [row + 1, next.emojis[column] ? column : next.emojis.length - 1] + Match.when("ArrowRight", (): Position => { + if (column < (rows[row]?.emojis.length ?? 1) - 1) return { row, column: column + 1 } + return next ? { row: row + 1, column: 0 } : { row, column } }), - Match.orElse(() => undefined), + Match.when( + "ArrowUp", + (): Position => + previous + ? { row: row - 1, column: previous.emojis[column] ? column : lastColumnOf(previous) } + : { row, column }, + ), + Match.when( + "ArrowDown", + (): Position => + next + ? { row: row + 1, column: next.emojis[column] ? column : lastColumnOf(next) } + : { row, column }, + ), + Match.orElse((): Position => ({ row, column })), + ) +} + +const moveActive = (model: Model, key: string): Model => { + const rows = pickerData(model)?.rows ?? [] + if (model.interaction === "None" || rows.length === 0) return withActive(model, "Keyboard", 0, 0) + const { row, column } = movedPosition( + rows, + { row: model.activeRowIndex, column: model.activeColumnIndex }, + key, ) - return { ...model, interaction: "keyboard", activeColumnIndex: column, activeRowIndex: row } + return withActive(model, "Keyboard", row, column) } const select = (model: Model, emoji: PickerEmoji | null): Return => emoji === null ? { model } - : { model, outMessage: OutMessage.SelectedEmoji({ emoji: emoji.emoji, label: emoji.label, imageUrl: null }) } + : { + model, + outMessage: OutMessage.SelectedEmoji({ + emoji: emoji.emoji, + label: emoji.label, + imageUrl: null, + }), + } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { // `onDataChange` resets the active emoji. - SucceededLoadEmojiData: ({ data }) => ({ model: { ...model, data, activeColumnIndex: 0, activeRowIndex: 0 } }), - FailedLoadEmojiData: () => ({ model }), + SucceededLoadEmojiData: ({ data }) => ({ + model: modifyFields(withActive(model, model.interaction, 0, 0), { + data: () => AsyncData.Success({ data }), + }), + }), + FailedLoadEmojiData: ({ reason }) => ({ + model: modifyFields(model, { + data: (previous) => AsyncData.settle(previous, Result.fail(reason)), + }), + }), + ClickedRetryLoadEmojiData: () => loadData(model), UpdatedSearch: ({ search }) => ({ - model: { ...model, search, interaction: search ? "keyboard" : "none", activeColumnIndex: 0, activeRowIndex: 0 }, + model: modifyFields(withActive(model, search ? "Keyboard" : "None", 0, 0), { + search: () => search, + }), }), - MeasuredPicker: (measured) => ({ model: { ...model, ...measured } }), - ScrolledViewport: ({ scrollTop }) => ({ model: { ...model, scrollTop } }), + MeasuredPicker: ({ rowHeight, categoryHeaderHeight, viewportWidth, viewportHeight }) => ({ + model: modifyFields(model, { + rowHeight: () => rowHeight, + categoryHeaderHeight: () => categoryHeaderHeight, + viewportWidth: () => viewportWidth, + viewportHeight: () => viewportHeight, + }), + }), + ScrolledViewport: ({ scrollTop }) => ({ model: modifyFields(model, { scrollTop: () => scrollTop }) }), FocusedSearch: () => ({ - model: { ...model, isFocusedWithin: true, interaction: model.search === "" ? "none" : model.interaction }, + model: modifyFields(model, { + isFocusedWithin: () => true, + interaction: (interaction) => (model.search === "" ? "None" : interaction), + }), }), FocusedViewport: () => ({ - model: { ...model, isFocusedWithin: true, interaction: "keyboard", activeColumnIndex: 0, activeRowIndex: 0 }, + model: modifyFields(withActive(model, "Keyboard", 0, 0), { isFocusedWithin: () => true }), }), - BlurredPicker: () => ({ model: resetActive({ ...model, isFocusedWithin: false }) }), - PressedNavigationKey: ({ key }) => (key === "Enter" ? select(model, activeEmoji(model)) : { model: moveActive(model, key) }), + BlurredPicker: () => ({ model: resetActive(modifyFields(model, { isFocusedWithin: () => false })) }), + PressedNavigationKey: ({ key }) => + key === "Enter" ? select(model, activeEmoji(model)) : { model: moveActive(model, key) }, HoveredEmoji: ({ rowIndex, columnIndex }) => ({ - model: { ...model, interaction: "pointer", activeRowIndex: rowIndex, activeColumnIndex: columnIndex }, + model: withActive(model, "Pointer", rowIndex, columnIndex), }), UnhoveredEmoji: () => ({ model: resetActive(model) }), ClickedEmoji: ({ rowIndex, columnIndex }) => select(model, pickerData(model)?.rows[rowIndex]?.emojis[columnIndex] ?? null), ClickedCustomEmoji: ({ name, imageUrl }) => ({ model, - outMessage: OutMessage.SelectedEmoji({ emoji: formatCustomEmojiKey(name), label: name, imageUrl }), + outMessage: OutMessage.SelectedEmoji({ + emoji: formatCustomEmojiKey(name), + label: name, + imageUrl, + }), }), }) // MOUNT type MeasureMessage = typeof Message.MeasuredPicker.Type | typeof Message.ScrolledViewport.Type -type KeyMessage = typeof Message.PressedNavigationKey.Type | typeof Message.BlurredPicker.Type +type FocusOutMessage = typeof Message.BlurredPicker.Type -/** The sizers' and viewport's sizes (ResizeObserver) and the viewport's scroll position. */ +/** The viewport's and its sizers' sizes (ResizeObserver) and the viewport's scroll position. */ export const MeasurePicker = Mount.defineStream("MeasurePicker", { messages: [Message.MeasuredPicker, Message.ScrolledViewport], - execute: ({ element }) => + execute: ({ element: viewport }) => Stream.callback<MeasureMessage>((queue) => Effect.acquireRelease( Effect.sync(() => { - const query = (selector: string) => element.querySelector<HTMLElement>(selector) - const viewport = query("[frimousse-viewport]") - const row = query("[frimousse-row-sizer]") - const header = query("[frimousse-category-header-sizer]") - if (!viewport || !row || !header) return () => undefined + const row = viewport.querySelector<HTMLElement>("[frimousse-row-sizer]") + const header = viewport.querySelector<HTMLElement>("[frimousse-category-header-sizer]") + if (!(viewport instanceof HTMLElement) || !row || !header) return () => undefined const measure = () => Queue.offerUnsafe( queue, @@ -230,7 +328,8 @@ export const MeasurePicker = Mount.defineStream("MeasurePicker", { observer.observe(row) observer.observe(header) measure() - const onScroll = () => Queue.offerUnsafe(queue, Message.ScrolledViewport({ scrollTop: viewport.scrollTop })) + const onScroll = () => + Queue.offerUnsafe(queue, Message.ScrolledViewport({ scrollTop: viewport.scrollTop })) viewport.addEventListener("scroll", onScroll, { passive: true }) return () => { observer.disconnect() @@ -242,31 +341,20 @@ export const MeasurePicker = Mount.defineStream("MeasurePicker", { ), }) -/** frimousse's document keydown while focus is inside the picker, and its blur-capture. */ -export const TrackPickerKeys = Mount.defineStream("TrackPickerKeys", { - messages: [Message.PressedNavigationKey, Message.BlurredPicker], - execute: ({ element: wrapper }) => - Stream.callback<KeyMessage>((queue) => +/** frimousse's blur capture: focus leaving the picker root resets the active emoji. */ +export const TrackPickerFocusOut = Mount.defineStream("TrackPickerFocusOut", { + messages: [Message.BlurredPicker], + execute: ({ element }) => + Stream.callback<FocusOutMessage>((queue) => Effect.acquireRelease( Effect.sync(() => { - const element = wrapper.closest<HTMLElement>("[frimousse-root]") ?? (wrapper as HTMLElement) - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || !element.contains(document.activeElement)) return - if (!event.key.startsWith("Arrow") && event.key !== "Enter") return - event.preventDefault() - Queue.offerUnsafe(queue, Message.PressedNavigationKey({ key: event.key })) - } - const onFocusOut = (event: FocusEvent) => { - const related = event.relatedTarget + const onFocusOut = (event: Event) => { + const related = event instanceof FocusEvent ? event.relatedTarget : null if (!(related instanceof Node) || !element.contains(related)) Queue.offerUnsafe(queue, Message.BlurredPicker()) } - document.addEventListener("keydown", onKeyDown) element.addEventListener("focusout", onFocusOut) - return () => { - document.removeEventListener("keydown", onKeyDown) - element.removeEventListener("focusout", onFocusOut) - } + return () => element.removeEventListener("focusout", onFocusOut) }), (release) => Effect.sync(release), ).pipe(Effect.flatMap(() => Effect.never)), diff --git a/apps/web-foldkit/src/emoji-picker/story.test.ts b/apps/web-foldkit/src/emoji-picker/story.test.ts index 359de47f0..3cb32e220 100644 --- a/apps/web-foldkit/src/emoji-picker/story.test.ts +++ b/apps/web-foldkit/src/emoji-picker/story.test.ts @@ -1,9 +1,21 @@ +import { AsyncData } from "foldkit" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { PopoverEvent } from "../picker-popover/popover" -import type { EmojiData } from "./data" +import { type EmojiData, pickerDataOf } from "./data" import * as Dialog from "./dialog" -import { activeEmoji, init, LoadEmojiData, Message, OutMessage, pickerData, update, viewportRange } from "./picker" +import { + activeEmoji, + EmojiDataAsync, + init, + isLoading, + LoadEmojiData, + Message, + OutMessage, + pickerData, + update, + viewportRange, +} from "./picker" /** frimousse behavior: rows of nine per category, its virtualized range, pointer and keyboard picks. */ @@ -23,24 +35,37 @@ const data: EmojiData = { { index: 0, label: "Smileys & emotion" }, { index: 1, label: "People & body" }, ], - skinTones: { light: "Light", "medium-light": "Medium-light", medium: "Medium", "medium-dark": "Medium-dark", dark: "Dark" }, + skinTones: { + light: "Light", + "medium-light": "Medium-light", + medium: "Medium", + "medium-dark": "Medium-dark", + dark: "Dark", + }, } -const measured = Message.MeasuredPicker({ rowHeight: 40, categoryHeaderHeight: 40, viewportWidth: 376, viewportHeight: 222 }) +const measured = Message.MeasuredPicker({ + rowHeight: 40, + categoryHeaderHeight: 40, + viewportWidth: 376, + viewportHeight: 222, +}) + +const loaded = () => update(init("picker"), Message.SucceededLoadEmojiData({ data })).model describe("emoji picker", () => { test("rows chunk by nine per category and the range has frimousse's overscan", () => { - const loaded = update(update(init("picker"), Message.SucceededLoadEmojiData({ data })).model, measured).model - const rows = pickerData(loaded)! + const current = update(loaded(), measured).model + const rows = pickerData(current)! expect(rows.categoriesStartRowIndices).toEqual([0, 4]) expect(rows.rows.length).toBe(8) - expect(viewportRange(loaded, rows)).toEqual({ startRowIndex: 0, endRowIndex: 6 }) + expect(viewportRange(current, rows)).toEqual({ startRowIndex: 0, endRowIndex: 6 }) }) test("hover makes an emoji active (the footer shows it); a click selects it", () => { story( update, - given(update(init("picker"), Message.SucceededLoadEmojiData({ data })).model), + given(loaded()), message(Message.HoveredEmoji({ rowIndex: 1, columnIndex: 2 })), model((current) => expect(activeEmoji(current)?.label).toBe("s 12")), message(Message.ClickedEmoji({ rowIndex: 1, columnIndex: 2 })), @@ -51,7 +76,7 @@ describe("emoji picker", () => { test("arrow keys move the active emoji across rows; Enter selects it", () => { story( update, - given(update(init("picker"), Message.SucceededLoadEmojiData({ data })).model), + given(loaded()), message(Message.FocusedViewport()), message(Message.PressedNavigationKey({ key: "ArrowLeft" })), model((current) => expect([current.activeRowIndex, current.activeColumnIndex]).toEqual([0, 0])), @@ -62,10 +87,56 @@ describe("emoji picker", () => { ) }) + test("arrows wrap across row ends and clamp to shorter rows", () => { + story( + update, + given(loaded()), + message(Message.FocusedViewport()), + // Row 0 to the end of the category's last (one-emoji) row: column 0 stays. + message(Message.PressedNavigationKey({ key: "ArrowDown" })), + message(Message.PressedNavigationKey({ key: "ArrowDown" })), + message(Message.PressedNavigationKey({ key: "ArrowDown" })), + model((current) => expect([current.activeRowIndex, current.activeColumnIndex]).toEqual([3, 0])), + // Left from column 0 wraps to the previous row's last emoji. + message(Message.PressedNavigationKey({ key: "ArrowLeft" })), + model((current) => expect([current.activeRowIndex, current.activeColumnIndex]).toEqual([2, 8])), + // Down from column 8 onto the one-emoji row clamps to its last column. + message(Message.PressedNavigationKey({ key: "ArrowDown" })), + model((current) => expect([current.activeRowIndex, current.activeColumnIndex]).toEqual([3, 0])), + ) + }) + test("searching scores labels and tags", () => { - const searched = update(update(init("picker"), Message.SucceededLoadEmojiData({ data })).model, Message.UpdatedSearch({ search: "wave" })).model + const searched = update(loaded(), Message.UpdatedSearch({ search: "wave" })).model expect(pickerData(searched)!.count).toBe(2) - expect(searched.interaction).toBe("keyboard") + expect(searched.interaction).toBe("Keyboard") + }) + + test("a failed load shows the failure instead of a spinner; retrying loads again", () => { + story( + update, + given({ ...init("picker"), data: AsyncData.Loading() }), + message(Message.FailedLoadEmojiData({ reason: "Offline" })), + model((current) => { + expect(current.data).toEqual(EmojiDataAsync.Failure({ error: "Offline" })) + expect(isLoading(current)).toBe(false) + }), + message(Message.ClickedRetryLoadEmojiData()), + Command.expectExact(LoadEmojiData()), + model((current) => { + expect(AsyncData.isLoading(current.data)).toBe(true) + expect(isLoading(current)).toBe(true) + }), + Command.resolve(LoadEmojiData, Message.SucceededLoadEmojiData({ data })), + model((current) => expect(pickerData(current)?.count).toBe(58)), + ) + }) + + test("the picker data memo stays bounded and returns the same rows for a repeated search", () => { + const first = pickerDataOf(data, 9, "s") + for (let index = 0; index < 20; index += 1) pickerDataOf(data, 9, `query-${index}`) + expect(pickerDataOf(data, 9, "s")).not.toBe(first) + expect(pickerDataOf(data, 9, "s")).toBe(pickerDataOf(data, 9, "s")) }) test("the dialog loads data on open and closes on a pick, reporting the emoji", () => { @@ -74,9 +145,20 @@ describe("emoji picker", () => { given(Dialog.init("reaction")), message(Dialog.Message.ReceivedPopoverEvent({ event: PopoverEvent.ClickedTrigger() })), Command.expectExact(LoadEmojiData()), + model((current) => expect(AsyncData.isLoading(current.picker.data)).toBe(true)), Command.resolve(LoadEmojiData, Message.SucceededLoadEmojiData({ data })), - message(Dialog.Message.GotPickerMessage({ message: Message.ClickedCustomEmoji({ name: "shipit", imageUrl: "/shipit.png" }) })), - expectOutMessage(OutMessage.SelectedEmoji({ emoji: "custom:shipit", label: "shipit", imageUrl: "/shipit.png" })), + message( + Dialog.Message.GotPickerMessage({ + message: Message.ClickedCustomEmoji({ name: "shipit", imageUrl: "/shipit.png" }), + }), + ), + expectOutMessage( + OutMessage.SelectedEmoji({ + emoji: "custom:shipit", + label: "shipit", + imageUrl: "/shipit.png", + }), + ), model((current) => expect(current.isOpen).toBe(false)), ) }) diff --git a/apps/web-foldkit/src/emoji-picker/view.ts b/apps/web-foldkit/src/emoji-picker/view.ts index dcf50c733..98db36c93 100644 --- a/apps/web-foldkit/src/emoji-picker/view.ts +++ b/apps/web-foldkit/src/emoji-picker/view.ts @@ -1,17 +1,22 @@ -import { Mount } from "foldkit" +import { Option } from "effect" +import { AsyncData, Mount } from "foldkit" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "~/lib/error-messages" import { cn } from "~/lib/utils" import { IconLoader, IconMagnifier3 } from "../icons" +import { button } from "../ui/button" import { EMOJI_FONT_FAMILY, type PickerCategory, type PickerData, type PickerEmoji } from "./data" import { activeEmoji, COLUMNS, isLoading, + isNavigationKey, + type LoadEmojiDataFailure, MeasurePicker, Message, type Model, pickerData, - TrackPickerKeys, + TrackPickerFocusOut, viewportRange, } from "./picker" @@ -25,7 +30,8 @@ const VISUALLY_HIDDEN = const EMOJI_CLASS = "flex size-10 items-center justify-center rounded-md text-2xl data-[active]:bg-accent" const ROW_CLASS = "scroll-my-1.5 px-2" -export const CATEGORY_HEADER_CLASS = "bg-bg px-3 pt-4 pb-2.5 font-medium text-secondary-fg text-sm leading-none" +export const CATEGORY_HEADER_CLASS = + "bg-bg px-3 pt-4 pb-2.5 font-medium text-secondary-fg text-sm leading-none" const px = (value: number | null) => (value === null ? "" : `${value}px`) @@ -77,7 +83,10 @@ const sizers = <M>(h: HtmlBuilder<M>): Html => h.div( [ h.Attribute("frimousse-category", ""), - h.Attribute("style", "contain: content; width: 100%; pointer-events: none; position: absolute;"), + h.Attribute( + "style", + "contain: content; width: 100%; pointer-events: none; position: absolute;", + ), ], [ h.div( @@ -126,11 +135,18 @@ const categoryView = <M>(h: HtmlBuilder<M>, category: PickerCategory, index: num ) /** `EmojiPicker.List` with the legacy Row, Emoji and CategoryHeader components. */ -const listView = <M>(h: HtmlBuilder<M>, model: Model, data: PickerData | null, toMessage: (message: Message) => M): Html => { +const listView = <M>( + h: HtmlBuilder<M>, + model: Model, + data: PickerData | null, + toMessage: (message: Message) => M, +): Html => { const rowsCount = data?.rows.length ?? 0 const categoriesCount = data?.categories.length ?? 0 const range = data === null ? { startRowIndex: 0, endRowIndex: 0 } : viewportRange(model, data) - const previousHeaders = (data?.categoriesStartRowIndices ?? []).filter((index) => index < range.startRowIndex).length + const previousHeaders = (data?.categoriesStartRowIndices ?? []).filter( + (index) => index < range.startRowIndex, + ).length const active = activeEmoji(model) const rows = data === null || rowsCount === 0 @@ -141,7 +157,14 @@ const listView = <M>(h: HtmlBuilder<M>, model: Model, data: PickerData | null, t if (row === undefined) return [] return [ ...(data.categoriesStartRowIndices.includes(rowIndex) - ? [h.keyed("div")(`header-space-${rowIndex}`, [h.Attribute("style", "height: var(--frimousse-category-header-height);")])] + ? [ + h.keyed("div")(`header-space-${rowIndex}`, [ + h.Attribute( + "style", + "height: var(--frimousse-category-header-height);", + ), + ]), + ] : []), h.keyed("div")( `row-${rowIndex}`, @@ -151,12 +174,17 @@ const listView = <M>(h: HtmlBuilder<M>, model: Model, data: PickerData | null, t h.Attribute("data-slot", "emoji-picker-row"), h.Attribute("frimousse-row", ""), h.Role("row"), - h.Attribute("style", "contain: content; height: var(--frimousse-row-height); display: flex;"), + h.Attribute( + "style", + "contain: content; height: var(--frimousse-row-height); display: flex;", + ), ], row.emojis.map((emoji, columnIndex) => emojiButton(h, emoji, columnIndex, active?.emoji === emoji.emoji, [ h.OnClick(toMessage(Message.ClickedEmoji({ rowIndex, columnIndex }))), - h.OnMouseEnter(toMessage(Message.HoveredEmoji({ rowIndex, columnIndex }))), + h.OnMouseEnter( + toMessage(Message.HoveredEmoji({ rowIndex, columnIndex })), + ), h.OnMouseLeave(toMessage(Message.UnhoveredEmoji())), ]), ), @@ -182,13 +210,49 @@ const listView = <M>(h: HtmlBuilder<M>, model: Model, data: PickerData | null, t `position: relative; box-sizing: border-box; height: calc(${rowsCount} * var(--frimousse-row-height) + ${categoriesCount} * var(--frimousse-category-header-height)); padding-top: calc(${range.startRowIndex} * var(--frimousse-row-height) + ${previousHeaders} * var(--frimousse-category-header-height));`, ), ], - [sizers(h), ...rows, ...(data?.categories ?? []).map((category, index) => categoryView(h, category, index))], + [ + sizers(h), + ...rows, + ...(data?.categories ?? []).map((category, index) => categoryView(h, category, index)), + ], ), ], ) } -/** `EmojiPickerContent`: the viewport with its loading and empty states and the list. */ +/** The dataset could not load: what went wrong, and a way to try again. */ +const failureView = <M>( + h: HtmlBuilder<M>, + reason: LoadEmojiDataFailure, + toMessage: (message: Message) => M, +): Html => { + const copy = failureMessage(reason, {}) + return h.div( + [ + h.Class("absolute inset-0 flex flex-col items-center justify-center gap-3 px-6 text-center"), + h.Attribute("data-slot", "emoji-picker-error"), + h.Role("alert"), + ], + [ + h.div( + [h.Class("flex flex-col gap-1")], + [ + h.span([h.Class("font-medium text-fg text-sm")], [copy.title]), + copy.description === undefined + ? h.empty + : h.span([h.Class("text-muted-fg text-xs")], [copy.description]), + ], + ), + button( + h, + { intent: "outline", size: "sm", onPress: toMessage(Message.ClickedRetryLoadEmojiData()) }, + ["Try again"], + ), + ], + ) +} + +/** `EmojiPickerContent`: the viewport with its loading, failure and empty states and the list. */ const viewportView = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => { const data = pickerData(model) const active = activeEmoji(model) @@ -202,13 +266,23 @@ const viewportView = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: M h.Attribute("data-slot", "emoji-picker-viewport"), h.Attribute("frimousse-viewport", ""), h.OnFocus(toMessage(Message.FocusedViewport())), + h.OnMount(Mount.mapMessage(MeasurePicker(), toMessage)), h.Attribute( "style", `position: relative; box-sizing: border-box; contain: layout paint;${intrinsic} overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; will-change: scroll-position;`, ), ], [ - active === null ? h.empty : h.div([h.Attribute("aria-live", "polite"), h.Attribute("style", VISUALLY_HIDDEN)], [active.label]), + active === null + ? h.empty + : h.div( + [h.Attribute("aria-live", "polite"), h.Attribute("style", VISUALLY_HIDDEN)], + [active.label], + ), + Option.match(AsyncData.getError(model.data), { + onNone: () => h.empty, + onSome: (reason) => failureView(h, reason, toMessage), + }), isLoading(model) ? h.span( [ @@ -222,7 +296,9 @@ const viewportView = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: M data !== null && data.count === 0 ? h.span( [ - h.Class("absolute inset-0 flex items-center justify-center text-secondary-fg text-sm"), + h.Class( + "absolute inset-0 flex items-center justify-center text-secondary-fg text-sm", + ), h.Attribute("data-slot", "emoji-picker-empty"), h.Attribute("frimousse-empty", ""), ], @@ -239,13 +315,23 @@ const footerView = <M>(h: HtmlBuilder<M>, model: Model): Html => { const active = activeEmoji(model) return h.div( [ - h.Class("flex w-full min-w-0 max-w-(--frimousse-viewport-width) items-center gap-2 border-border border-t p-3"), + h.Class( + "flex w-full min-w-0 max-w-(--frimousse-viewport-width) items-center gap-2 border-border border-t p-3", + ), h.Attribute("data-slot", "emoji-picker-footer"), ], active === null - ? [h.span([h.Class("ml-1.5 flex h-10 items-center truncate text-secondary-fg text-sm")], ["Select an emoji…"])] + ? [ + h.span( + [h.Class("ml-1.5 flex h-10 items-center truncate text-secondary-fg text-sm")], + ["Select an emoji…"], + ), + ] : [ - h.div([h.Class("flex size-10 flex-none items-center justify-center text-2xl")], [active.emoji]), + h.div( + [h.Class("flex size-10 flex-none items-center justify-center text-2xl")], + [active.emoji], + ), h.span([h.Class("truncate text-secondary-fg text-sm")], [active.label]), ], ) @@ -261,26 +347,41 @@ export interface PickerViewInputs<M> { /** `<EmojiPicker className=...><EmojiPickerSearch /><EmojiPickerContent />…<EmojiPickerFooter /></EmojiPicker>`. */ export const pickerView = <M>(h: HtmlBuilder<M>, model: Model, inputs: PickerViewInputs<M>): Html => { const { toMessage } = inputs - const measured = model.rowHeight === null ? "" : ` --frimousse-viewport-width: ${px(model.viewportWidth)}; --frimousse-viewport-height: ${px(model.viewportHeight)}; --frimousse-row-height: ${px(model.rowHeight)}; --frimousse-category-header-height: ${px(model.categoryHeaderHeight)};` + const measured = + model.rowHeight === null + ? "" + : ` --frimousse-viewport-width: ${px(model.viewportWidth)}; --frimousse-viewport-height: ${px(model.viewportHeight)}; --frimousse-row-height: ${px(model.rowHeight)}; --frimousse-category-header-height: ${px(model.categoryHeaderHeight)};` return h.div( [ - h.Class(cn("isolate flex h-full w-fit flex-col overflow-hidden rounded-md border-border bg-secondary text-fg", inputs.className)), + h.Class( + cn( + "isolate flex h-full w-fit flex-col overflow-hidden rounded-md border-border bg-secondary text-fg", + inputs.className, + ), + ), ...(model.isFocusedWithin ? [h.Attribute("data-focused", "")] : []), h.Attribute("data-slot", "emoji-picker"), h.Attribute("frimousse-root", ""), h.Attribute("style", `--frimousse-emoji-font: ${EMOJI_FONT_FAMILY};${measured}`), - h.OnMount(Mount.mapMessage(MeasurePicker(), toMessage)), + // frimousse's keydown while focus is inside the picker: arrows move, Enter picks the active + // emoji (with none active, Enter keeps activating the focused button, e.g. "Try again"). + h.OnKeyDownPreventDefault((key) => + isNavigationKey(key) && (key !== "Enter" || activeEmoji(model) !== null) + ? Option.some(toMessage(Message.PressedNavigationKey({ key }))) + : Option.none(), + ), + h.OnMount(Mount.mapMessage(TrackPickerFocusOut(), toMessage)), ], [ h.div( [ h.Class("flex h-11 items-center gap-2.5 border-border border-b px-3.5"), h.Attribute("data-slot", "emoji-picker-search-wrapper"), - h.OnMount(Mount.mapMessage(TrackPickerKeys(), toMessage)), ], [ IconMagnifier3(h, { className: "size-5 shrink-0 opacity-50" }), h.input([ + h.AriaLabel("Search emoji"), h.Attribute("autocapitalize", "off"), h.Attribute("autocomplete", "off"), h.Attribute("autocorrect", "off"), diff --git a/apps/web-foldkit/src/gif-picker/picker.ts b/apps/web-foldkit/src/gif-picker/picker.ts index ba60dda59..1a8c05412 100644 --- a/apps/web-foldkit/src/gif-picker/picker.ts +++ b/apps/web-foldkit/src/gif-picker/picker.ts @@ -1,9 +1,14 @@ import type { KlipyGif } from "@hazel/domain/http" -import { Duration, Effect, Schema } from "effect" -import { Command, type Update } from "foldkit" +import { Duration, Effect, Option, Result, Schema } from "effect" +import { AsyncData, Command, Update } from "foldkit" +import * as Dom from "foldkit/dom" import { defineMessageUnion } from "foldkit/message" -import { HazelApiClient } from "../rpc" +import { modifyFields } from "foldkit/struct" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" import { PopoverEvent } from "../picker-popover/popover" +import { HazelApiClient } from "../rpc" +import { searchFieldIds } from "../ui/search-field" /** * `GifPickerDialog` with `useKlipy`: trending GIFs and categories on open, a 300ms debounced @@ -28,24 +33,42 @@ export type Gif = typeof Gif.Type export const Category = Schema.Struct({ category: Schema.String, query: Schema.String }) +/** What can go wrong fetching from KLIPY: the provider itself failing (`KlipyApiError`), or the shared reasons. */ +export const GifFailure = Schema.Union([Schema.Literals(["ProviderFailed"]), CommonReason]) +export type GifFailure = typeof GifFailure.Type + +/** The GIFs loaded so far for one query, through `page`. */ +export const GifPage = Schema.Struct({ gifs: Schema.Array(Gif), page: Schema.Number }) +export type GifPage = typeof GifPage.Type + +export const GifResults = AsyncData.Schema(GifPage, GifFailure) +export const Categories = AsyncData.Schema(Schema.Array(Category), GifFailure) + +/** Paging past the first page: ready, fetching, failed (with why), or nothing left. */ +export const More = Schema.Union([ + Schema.TaggedStruct("Idle", {}), + Schema.TaggedStruct("Loading", {}), + Schema.TaggedStruct("Failed", { reason: GifFailure }), + Schema.TaggedStruct("Exhausted", {}), +]) +export type More = typeof More.Type + +const moreAfter = (hasMore: boolean): More => (hasMore ? { _tag: "Idle" } : { _tag: "Exhausted" }) + export const Model = Schema.Struct({ id: Schema.String, isOpen: Schema.Boolean, query: Schema.String, selectedCategory: Schema.NullOr(Schema.String), - categories: Schema.Array(Category), - gifs: Schema.Array(Gif), - isLoading: Schema.Boolean, - isLoadingMore: Schema.Boolean, - hasMore: Schema.Boolean, - page: Schema.Number, - /** The query the shown GIFs answer ("" is trending). */ + categories: Categories.schema, + /** The query `results` answer, or are loading for ("" is trending). Responses for any other query are stale. */ currentQuery: Schema.String, - requestId: Schema.Number, + results: GifResults.schema, + more: More, debounceVersion: Schema.Number, hoveredSlug: Schema.NullOr(Schema.String), /** The trending query's first page; going back to "" shows it without refetching. */ - trending: Schema.NullOr(Schema.Struct({ gifs: Schema.Array(Gif), page: Schema.Number, hasMore: Schema.Boolean })), + trending: Schema.NullOr(Schema.Struct({ page: GifPage, hasMore: Schema.Boolean })), }) export type Model = typeof Model.Type @@ -54,19 +77,21 @@ export const init = (id: string): Model => ({ isOpen: false, query: "", selectedCategory: null, - categories: [], - gifs: [], - isLoading: true, - isLoadingMore: false, - hasMore: true, - page: 1, + categories: AsyncData.Idle(), currentQuery: "", - requestId: 0, + results: AsyncData.Idle(), + more: { _tag: "Idle" }, debounceVersion: 0, hoveredSlug: null, trending: null, }) +export const searchInputId = (model: Model) => searchFieldIds(`${model.id}-search`).input + +/** The GIFs on screen (none while the first page loads or failed). */ +export const gifsOf = (model: Model): ReadonlyArray<Gif> => + Option.match(AsyncData.getData(model.results), { onNone: () => [], onSome: (page) => page.gifs }) + // MESSAGE export const Message = defineMessageUnion({ @@ -75,15 +100,16 @@ export const Message = defineMessageUnion({ ClickedCategory: { category: Schema.String }, CompletedWaitForGifSearch: { version: Schema.Number }, SucceededFetchGifs: { - requestId: Schema.Number, - append: Schema.Boolean, - gifs: Schema.Array(Gif), + query: Schema.String, page: Schema.Number, + gifs: Schema.Array(Gif), hasMore: Schema.Boolean, }, - FailedFetchGifs: { requestId: Schema.Number }, + FailedFetchGifs: { query: Schema.String, page: Schema.Number, reason: GifFailure }, + ClickedRetryFetchGifs: {}, SucceededFetchCategories: { categories: Schema.Array(Category) }, - FailedFetchCategories: {}, + FailedFetchCategories: { reason: GifFailure }, + ClickedRetryFetchCategories: {}, ScrolledNearEnd: {}, HoveredGif: { slug: Schema.NullOr(Schema.String) }, ClickedGif: { slug: Schema.String }, @@ -108,23 +134,28 @@ const toGif = (gif: KlipyGif): Gif => ({ /** `fetchGifs`: `klipy.search` with a query, `klipy.trending` without. */ export const FetchGifs = Command.define("FetchGifs", { - args: { query: Schema.String, page: Schema.Number, append: Schema.Boolean, requestId: Schema.Number }, + args: { query: Schema.String, page: Schema.Number }, messages: [Message.SucceededFetchGifs, Message.FailedFetchGifs], - execute: ({ query, page, append, requestId }) => + execute: ({ query, page }) => (query - ? HazelApiClient.use((client) => client.klipy.search({ query: { q: query, page, per_page: PER_PAGE } })) + ? HazelApiClient.use((client) => + client.klipy.search({ query: { q: query, page, per_page: PER_PAGE } }), + ) : HazelApiClient.use((client) => client.klipy.trending({ query: { page, per_page: PER_PAGE } })) ).pipe( Effect.map((response) => Message.SucceededFetchGifs({ - requestId, - append, - gifs: response.data.map(toGif), + query, page: response.current_page, + gifs: response.data.map(toGif), hasMore: response.has_next, }), ), - Effect.catchCause(() => Effect.succeed(Message.FailedFetchGifs({ requestId }))), + Effect.catchTags({ + KlipyApiError: () => + Effect.succeed(Message.FailedFetchGifs({ query, page, reason: "ProviderFailed" })), + }), + catchCommon((reason) => Message.FailedFetchGifs({ query, page, reason })), ), }) @@ -136,7 +167,10 @@ export const FetchCategories = Command.define("FetchCategories", { categories: response.categories.map(({ category, query }) => ({ category, query })), }), ), - Effect.catchCause(() => Effect.succeed(Message.FailedFetchCategories())), + Effect.catchTags({ + KlipyApiError: () => Effect.succeed(Message.FailedFetchCategories({ reason: "ProviderFailed" })), + }), + catchCommon((reason) => Message.FailedFetchCategories({ reason })), ), }) @@ -148,47 +182,83 @@ export const WaitForGifSearch = Command.define("WaitForGifSearch", { Effect.sleep(SEARCH_DEBOUNCE).pipe(Effect.as(Message.CompletedWaitForGifSearch({ version }))), }) +/** `SearchField autoFocus`: focus the search input once the popover is in the DOM. */ +export const FocusGifSearch = Command.define("FocusGifSearch", { + args: { inputId: Schema.String }, + messages: [Message.CompletedFocusGifSearch], + execute: ({ inputId }) => + Dom.focus(`#${CSS.escape(inputId)}`, { preventScroll: true }).pipe( + // Best effort: a popover closed before the focus lands has no input to focus. + Effect.ignore, + Effect.as(Message.CompletedFocusGifSearch()), + ), +}) + // UPDATE export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, HazelApiClient> +type StepReturn = Update.Return<Model, Message, HazelApiClient> -const fetch = (model: Model, query: string, page: number, append: boolean): Return => { - const requestId = model.requestId + 1 - return { - model: { ...model, requestId, isLoading: !append || model.isLoading, isLoadingMore: append }, - commands: [FetchGifs({ query, page, append, requestId })], - } -} +/** The first page of `query`, replacing whatever was shown. */ +const fetchFirstPage = (model: Model, query: string): StepReturn => ({ + model: modifyFields(model, { + currentQuery: () => query, + results: () => AsyncData.Loading(), + more: (): More => ({ _tag: "Idle" }), + }), + commands: [FetchGifs({ query, page: 1 })], +}) -/** `search(query)`: a fresh first page ("" goes back to trending). */ -const runSearch = (model: Model, query: string): Return => - query === "" && model.trending !== null +/** `search(query)`: a fresh first page ("" goes back to the cached trending page when there is one). */ +const runSearch = (model: Model, query: string): Return => { + const { trending } = model + return query === "" && trending !== null ? { - model: { - ...model, - currentQuery: "", - requestId: model.requestId + 1, - gifs: model.trending.gifs, - page: model.trending.page, - hasMore: model.trending.hasMore, - isLoading: false, - isLoadingMore: false, - }, + model: modifyFields(model, { + currentQuery: () => "", + results: () => GifResults.Success({ data: trending.page }), + more: () => moreAfter(trending.hasMore), + }), } - : fetch({ ...model, currentQuery: query, page: 1, hasMore: true, gifs: [] }, query, 1, false) + : fetchFirstPage(model, query) +} + +/** `loadMore`: the next page of the current query. */ +const fetchNextPage = (model: Model, current: GifPage): Return => ({ + model: modifyFields(model, { more: (): More => ({ _tag: "Loading" }) }), + commands: [FetchGifs({ query: model.currentQuery, page: current.page + 1 })], +}) + +const loadedPage = (model: Model): Option.Option<GifPage> => + AsyncData.isSuccess(model.results) ? AsyncData.getData(model.results) : Option.none() + +const close = (model: Model): Return => ({ model: modifyFields(model, { isOpen: () => false }) }) -const close = (model: Model): Return => ({ model: { ...model, isOpen: false } }) +/** Opening starts over: trending, the categories, and focus in the search field. */ +const open = (model: Model): Return => + Update.combine<Model, Message, HazelApiClient>(modifyFields(init(model.id), { isOpen: () => true }), [ + (opened) => fetchFirstPage(opened, ""), + (opened) => ({ + model: modifyFields(opened, { categories: () => Categories.Loading() }), + commands: [FetchCategories(), FocusGifSearch({ inputId: searchInputId(opened) })], + }), + ]) + +/** A first-page response still answers what is on screen: the same query, and it is still loading. */ +const isAwaitedFirstPage = (model: Model, query: string) => + query === model.currentQuery && AsyncData.isPending(model.results) + +/** A next-page response still extends what is on screen: the same query, the page after, still loading. */ +const isAwaitedNextPage = (model: Model, query: string, page: number) => + query === model.currentQuery && + model.more._tag === "Loading" && + Option.exists(loadedPage(model), (current) => current.page + 1 === page) export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { ReceivedPopoverEvent: ({ event }) => PopoverEvent.match<Return>(event, { - ClickedTrigger: () => { - if (model.isOpen) return close(model) - const opened = { ...init(model.id), isOpen: true, requestId: model.requestId } - const first = fetch(opened, "", 1, false) - return { ...first, commands: [...(first.commands ?? []), FetchCategories()] } - }, + ClickedTrigger: () => (model.isOpen ? close(model) : open(model)), ClickedDismiss: () => close(model), PressedEscape: () => close(model), PressedOutside: () => close(model), @@ -197,7 +267,11 @@ export const update = (model: Model, message: Message): Return => UpdatedQuery: ({ query }) => { const debounceVersion = model.debounceVersion + 1 return { - model: { ...model, query, selectedCategory: null, debounceVersion }, + model: modifyFields(model, { + query: () => query, + selectedCategory: () => null, + debounceVersion: () => debounceVersion, + }), commands: [WaitForGifSearch({ version: debounceVersion })], } }, @@ -206,40 +280,88 @@ export const update = (model: Model, message: Message): Return => // Selecting the selected chip again goes back to trending. ClickedCategory: ({ category }) => { const next = model.selectedCategory === category ? null : category - return runSearch({ ...model, selectedCategory: next, debounceVersion: model.debounceVersion + 1 }, next ?? "") + return runSearch( + modifyFields(model, { + selectedCategory: () => next, + debounceVersion: (version) => version + 1, + }), + next ?? "", + ) }, - SucceededFetchGifs: ({ requestId, append, gifs, page, hasMore }) => - requestId !== model.requestId - ? { model } - : { - model: { - ...model, - gifs: append ? [...model.gifs, ...gifs] : gifs, - page, - hasMore, - isLoading: false, - isLoadingMore: false, - trending: - model.currentQuery === "" && !append && model.trending === null - ? { gifs, page, hasMore } - : model.trending, - }, - }, - FailedFetchGifs: ({ requestId }) => - requestId !== model.requestId ? { model } : { model: { ...model, isLoading: false, isLoadingMore: false } }, - SucceededFetchCategories: ({ categories: loaded }) => ({ model: { ...model, categories: loaded } }), - FailedFetchCategories: () => ({ model }), - // `loadMore`: the next page of the current query, unless one is loading or none is left. + SucceededFetchGifs: ({ query, page, gifs, hasMore }) => { + if (page === 1) { + if (!isAwaitedFirstPage(model, query)) return { model } + const loaded: GifPage = { gifs, page } + return { + model: modifyFields(model, { + results: () => GifResults.Success({ data: loaded }), + more: () => moreAfter(hasMore), + trending: (trending) => + query === "" && trending === null ? { page: loaded, hasMore } : trending, + }), + } + } + if (!isAwaitedNextPage(model, query, page)) return { model } + return { + model: modifyFields(model, { + results: (results) => + AsyncData.map(results, (current) => ({ gifs: [...current.gifs, ...gifs], page })), + more: () => moreAfter(hasMore), + }), + } + }, + FailedFetchGifs: ({ query, page, reason }) => { + if (page === 1) + return isAwaitedFirstPage(model, query) + ? { + model: modifyFields(model, { + results: (results) => AsyncData.settle(results, Result.fail(reason)), + }), + } + : { model } + return isAwaitedNextPage(model, query, page) + ? { model: modifyFields(model, { more: (): More => ({ _tag: "Failed", reason }) }) } + : { model } + }, + ClickedRetryFetchGifs: () => { + if (AsyncData.isFailure(model.results)) return fetchFirstPage(model, model.currentQuery) + return model.more._tag === "Failed" + ? Option.match(loadedPage(model), { + onNone: () => ({ model }), + onSome: (current) => fetchNextPage(model, current), + }) + : { model } + }, + SucceededFetchCategories: ({ categories }) => ({ + model: modifyFields(model, { categories: () => Categories.Success({ data: categories }) }), + }), + FailedFetchCategories: ({ reason }) => ({ + model: modifyFields(model, { + categories: (previous) => AsyncData.settle(previous, Result.fail(reason)), + }), + }), + ClickedRetryFetchCategories: () => ({ + model: modifyFields(model, { categories: () => Categories.Loading() }), + commands: [FetchCategories()], + }), + // `loadMore`: the next page of the current query, unless one is loading, failed or none is left. ScrolledNearEnd: () => - model.isLoading || model.isLoadingMore || !model.hasMore || model.gifs.length === 0 + model.more._tag !== "Idle" ? { model } - : fetch(model, model.currentQuery, model.page + 1, true), - HoveredGif: ({ slug }) => ({ model: { ...model, hoveredSlug: slug } }), + : Option.match(loadedPage(model), { + onNone: () => ({ model }), + onSome: (current) => + current.gifs.length === 0 ? { model } : fetchNextPage(model, current), + }), + HoveredGif: ({ slug }) => ({ model: modifyFields(model, { hoveredSlug: () => slug }) }), CompletedFocusGifSearch: () => ({ model }), ClickedGif: ({ slug }) => { - const gif = model.gifs.find((candidate) => candidate.slug === slug) + const gif = gifsOf(model).find((candidate) => candidate.slug === slug) return gif === undefined ? { model } - : { model: { ...model, isOpen: false }, outMessage: OutMessage.SelectedGif({ url: gif.hdGifUrl }) } + : { + model: modifyFields(model, { isOpen: () => false }), + outMessage: OutMessage.SelectedGif({ url: gif.hdGifUrl }), + } }, }) diff --git a/apps/web-foldkit/src/gif-picker/story.test.ts b/apps/web-foldkit/src/gif-picker/story.test.ts index a34f35779..71f90917a 100644 --- a/apps/web-foldkit/src/gif-picker/story.test.ts +++ b/apps/web-foldkit/src/gif-picker/story.test.ts @@ -1,9 +1,23 @@ +import { AsyncData } from "foldkit" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { PopoverEvent } from "../picker-popover/popover" -import { FetchCategories, FetchGifs, init, Message, OutMessage, update, WaitForGifSearch } from "./picker" +import { + FetchCategories, + FetchGifs, + FocusGifSearch, + GifResults, + gifsOf, + init, + Message, + type Model, + OutMessage, + searchInputId, + update, + WaitForGifSearch, +} from "./picker" -/** `useKlipy`'s calls: trending and categories on open, a debounced search, the HD URL out. */ +/** `useKlipy`'s calls: trending and categories on open, a debounced search, paging, the HD URL out. */ const gif = (slug: string) => ({ slug, @@ -15,27 +29,51 @@ const gif = (slug: string) => ({ hdGifUrl: `https://static.klipy.com/${slug}-hd.gif`, }) +const slugs = (current: Model) => gifsOf(current).map((candidate) => candidate.slug) + +/** An open picker showing one page of `query`. */ +const showing = ( + query: string, + gifs: ReadonlyArray<ReturnType<typeof gif>>, + more: Model["more"] = { _tag: "Idle" }, +): Model => ({ + ...init("gif"), + isOpen: true, + query, + currentQuery: query, + results: GifResults.Success({ data: { gifs, page: 1 } }), + more, +}) + describe("GIF picker", () => { - test("opening fetches trending and categories; a search runs after 300ms; a pick sends the HD GIF", () => { + test("opening fetches trending and categories and focuses the search; a search runs after 300ms; a pick sends the HD GIF", () => { story( update, given(init("gif")), message(Message.ReceivedPopoverEvent({ event: PopoverEvent.ClickedTrigger() })), - Command.expectExact(FetchGifs({ query: "", page: 1, append: false, requestId: 1 }), FetchCategories()), + Command.expectExact( + FetchGifs({ query: "", page: 1 }), + FetchCategories(), + FocusGifSearch({ inputId: searchInputId(init("gif")) }), + ), Command.resolve( FetchGifs, - Message.SucceededFetchGifs({ requestId: 1, append: false, gifs: [gif("wave")], page: 1, hasMore: true }), + Message.SucceededFetchGifs({ query: "", page: 1, gifs: [gif("wave")], hasMore: true }), ), Command.resolve(FetchCategories, Message.SucceededFetchCategories({ categories: [] })), + Command.resolve(FocusGifSearch, Message.CompletedFocusGifSearch()), message(Message.UpdatedQuery({ query: "cats" })), Command.expectExact(WaitForGifSearch({ version: 1 })), Command.resolve(WaitForGifSearch, Message.CompletedWaitForGifSearch({ version: 1 })), - Command.expectExact(FetchGifs({ query: "cats", page: 1, append: false, requestId: 2 })), + Command.expectExact(FetchGifs({ query: "cats", page: 1 })), Command.resolve( FetchGifs, - Message.SucceededFetchGifs({ requestId: 2, append: false, gifs: [gif("cat")], page: 1, hasMore: false }), + Message.SucceededFetchGifs({ query: "cats", page: 1, gifs: [gif("cat")], hasMore: false }), ), - model((current) => expect(current.gifs.map((candidate) => candidate.slug)).toEqual(["cat"])), + model((current) => { + expect(slugs(current)).toEqual(["cat"]) + expect(current.more._tag).toBe("Exhausted") + }), message(Message.ClickedGif({ slug: "cat" })), expectOutMessage(OutMessage.SelectedGif({ url: "https://static.klipy.com/cat-hd.gif" })), model((current) => expect(current.isOpen).toBe(false)), @@ -45,11 +83,106 @@ describe("GIF picker", () => { test("clearing the search goes back to the cached trending page without a request", () => { story( update, - given({ ...init("gif"), isOpen: true, query: "cats", currentQuery: "cats", gifs: [gif("cat")], trending: { gifs: [gif("wave")], page: 1, hasMore: true } }), + given({ + ...showing("cats", [gif("cat")]), + trending: { page: { gifs: [gif("wave")], page: 1 }, hasMore: true }, + }), message(Message.UpdatedQuery({ query: "" })), Command.resolve(WaitForGifSearch, Message.CompletedWaitForGifSearch({ version: 1 })), Command.expectNone(), - model((current) => expect(current.gifs.map((candidate) => candidate.slug)).toEqual(["wave"])), + model((current) => expect(slugs(current)).toEqual(["wave"])), + ) + }) + + test("a failed first page is an error with a retry, not an empty result", () => { + story( + update, + given({ ...init("gif"), isOpen: true, results: AsyncData.Loading() }), + message(Message.FailedFetchGifs({ query: "", page: 1, reason: "Offline" })), + model((current) => { + expect(current.results).toEqual(GifResults.Failure({ error: "Offline" })) + expect(gifsOf(current)).toEqual([]) + }), + message(Message.ClickedRetryFetchGifs()), + Command.expectExact(FetchGifs({ query: "", page: 1 })), + model((current) => expect(AsyncData.isLoading(current.results)).toBe(true)), + Command.resolve( + FetchGifs, + Message.SucceededFetchGifs({ query: "", page: 1, gifs: [gif("wave")], hasMore: true }), + ), + model((current) => expect(slugs(current)).toEqual(["wave"])), + ) + }) + + test("a failed next page keeps the GIFs, stops paging on scroll, and retries from its button", () => { + story( + update, + given(showing("cats", [gif("cat")])), + message(Message.ScrolledNearEnd()), + Command.expectExact(FetchGifs({ query: "cats", page: 2 })), + Command.resolve( + FetchGifs, + Message.FailedFetchGifs({ query: "cats", page: 2, reason: "ProviderFailed" }), + ), + model((current) => { + expect(current.more).toEqual({ _tag: "Failed", reason: "ProviderFailed" }) + expect(slugs(current)).toEqual(["cat"]) + }), + message(Message.ScrolledNearEnd()), + Command.expectNone(), + message(Message.ClickedRetryFetchGifs()), + Command.expectExact(FetchGifs({ query: "cats", page: 2 })), + Command.resolve( + FetchGifs, + Message.SucceededFetchGifs({ query: "cats", page: 2, gifs: [gif("kitten")], hasMore: false }), + ), + model((current) => { + expect(slugs(current)).toEqual(["cat", "kitten"]) + expect(current.more._tag).toBe("Exhausted") + }), + ) + }) + + test("results for a query no longer shown are ignored", () => { + story( + update, + given({ + ...init("gif"), + isOpen: true, + query: "dogs", + currentQuery: "dogs", + results: AsyncData.Loading(), + }), + message( + Message.SucceededFetchGifs({ query: "cats", page: 1, gifs: [gif("cat")], hasMore: true }), + ), + message(Message.FailedFetchGifs({ query: "cats", page: 1, reason: "Offline" })), + model((current) => expect(AsyncData.isLoading(current.results)).toBe(true)), + message( + Message.SucceededFetchGifs({ query: "dogs", page: 1, gifs: [gif("dog")], hasMore: true }), + ), + // A next page for the old query, landing after the new one, does not append to it. + message( + Message.SucceededFetchGifs({ query: "cats", page: 2, gifs: [gif("kitten")], hasMore: true }), + ), + model((current) => expect(slugs(current)).toEqual(["dog"])), + ) + }) + + test("failed categories are kept as a failure and can be retried", () => { + story( + update, + given({ ...showing("", [gif("wave")]), categories: AsyncData.Loading() }), + message(Message.FailedFetchCategories({ reason: "Unavailable" })), + model((current) => expect(AsyncData.getError(current.categories)._tag).toBe("Some")), + message(Message.ClickedRetryFetchCategories()), + Command.expectExact(FetchCategories()), + model((current) => expect(AsyncData.isLoading(current.categories)).toBe(true)), + Command.resolve( + FetchCategories, + Message.SucceededFetchCategories({ categories: [{ category: "Cats", query: "cats" }] }), + ), + model((current) => expect(AsyncData.getData(current.categories)._tag).toBe("Some")), ) }) }) diff --git a/apps/web-foldkit/src/gif-picker/view.ts b/apps/web-foldkit/src/gif-picker/view.ts index fd8d8b95f..3880bbbde 100644 --- a/apps/web-foldkit/src/gif-picker/view.ts +++ b/apps/web-foldkit/src/gif-picker/view.ts @@ -1,10 +1,12 @@ -import { Effect, Queue, Stream } from "effect" -import { Mount } from "foldkit" +import { Effect, Match, Queue, Stream } from "effect" +import { AsyncData, Mount } from "foldkit" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "~/lib/error-messages" import { pickerPopover } from "../picker-popover/popover" +import { button } from "../ui/button" import { loader } from "../ui/loader" -import { searchField, searchFieldIds } from "../ui/search-field" -import { Message, type Model } from "./picker" +import { searchField } from "../ui/search-field" +import { type Gif, type GifFailure, type GifPage, Message, type Model } from "./picker" /** `GifPickerContent`: search, category chips, the GIF grid and the KLIPY attribution. */ @@ -18,7 +20,10 @@ const TrackGifScroll = Mount.defineStream("TrackGifScroll", { Effect.acquireRelease( Effect.sync(() => { const onScroll = () => { - if (element.scrollTop + element.clientHeight >= element.scrollHeight - element.clientHeight * 0.5) + if ( + element.scrollTop + element.clientHeight >= + element.scrollHeight - element.clientHeight * 0.5 + ) Queue.offerUnsafe(queue, Message.ScrolledNearEnd()) } element.addEventListener("scroll", onScroll, { passive: true }) @@ -29,110 +34,194 @@ const TrackGifScroll = Mount.defineStream("TrackGifScroll", { ), }) -/** `SearchField autoFocus` focuses the input once it is in the popover. */ -const FocusGifSearch = Mount.define("FocusGifSearch", { - messages: [Message.CompletedFocusGifSearch], - execute: ({ element }) => - Effect.sync(() => { - requestAnimationFrame(() => element.querySelector("input")?.focus()) - return Message.CompletedFocusGifSearch() - }), -}) +const failureCopy = (reason: GifFailure) => + failureMessage(reason, { + ProviderFailed: { + title: "GIFs are unavailable", + description: "KLIPY didn't respond. Please try again.", + }, + }) + +const retryButton = <M>(h: HtmlBuilder<M>, onPress: M): Html => + button(h, { intent: "outline", size: "sm", onPress }, ["Try again"]) const categoryChips = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => - model.categories.length === 0 - ? h.empty - : h.div( - [h.Class("flex gap-1.5 overflow-x-auto px-3 pb-2 scrollbar-none")], - model.categories.slice(0, 12).map((category) => - h.keyed("button")( - category.query, + AsyncData.matchData(model.categories, { + onEmpty: () => h.empty, + // The chips are a shortcut; without them search still works, so the failure stays small. + onFailure: () => + h.div( + [ + h.Class("flex items-center gap-2 px-3 pb-2 text-muted-fg text-xs"), + h.Attribute("data-slot", "gif-categories-error"), + ], + [ + "Couldn't load categories.", + h.button( [ h.Attribute("type", "button"), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("tabindex", "0"), - h.OnClick(toMessage(Message.ClickedCategory({ category: category.category }))), h.Class( - `shrink-0 outline-none rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ - model.selectedCategory === category.category - ? "border-primary bg-primary text-on-primary" - : "border-fg/10 bg-muted/40 text-muted-fg hover:border-fg/20 hover:bg-muted hover:text-fg" - }`, + "rounded-sm font-medium text-fg underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-primary", ), + h.OnClick(toMessage(Message.ClickedRetryFetchCategories())), ], - [category.category], + ["Try again"], ), - ), - ) + ], + ), + onData: (categories) => + categories.length === 0 + ? h.empty + : h.div( + [h.Class("flex gap-1.5 overflow-x-auto px-3 pb-2 scrollbar-none")], + categories + .slice(0, 12) + .map((category) => + h.keyed("button")( + category.query, + [ + h.Attribute("type", "button"), + h.Attribute( + "aria-pressed", + model.selectedCategory === category.category ? "true" : "false", + ), + h.OnClick( + toMessage( + Message.ClickedCategory({ category: category.category }), + ), + ), + h.Class( + `shrink-0 outline-none rounded-full border px-2.5 py-0.5 text-xs transition-colors focus-visible:ring-2 focus-visible:ring-primary ${ + model.selectedCategory === category.category + ? "border-primary bg-primary text-on-primary" + : "border-fg/10 bg-muted/40 text-muted-fg hover:border-fg/20 hover:bg-muted hover:text-fg" + }`, + ), + ], + [category.category], + ), + ), + ), + }) -const emptyRow = <M>(h: HtmlBuilder<M>, content: Html | string): Html => +const centered = <M>(h: HtmlBuilder<M>, content: ReadonlyArray<Html | string>): Html => h.div( - [h.Role("row")], - [h.div([h.Role("gridcell"), h.Attribute("aria-colindex", "1")], [h.div([h.Class("absolute inset-0 flex items-center justify-center")], [content])])], + [h.Class("absolute inset-0 flex flex-col items-center justify-center gap-3 px-6 text-center")], + content, ) -/** `GifPickerGrid` (a React Aria GridList in grid layout). */ -const grid = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => - h.div( +const failureState = <M>(h: HtmlBuilder<M>, reason: GifFailure, toMessage: (message: Message) => M): Html => { + const copy = failureCopy(reason) + return h.div( + [h.Role("alert"), h.Attribute("data-slot", "gif-picker-error")], [ - h.Role("grid"), - h.Attribute("aria-label", "GIF results"), - h.Attribute("data-rac", ""), - h.Attribute("data-layout", "grid"), - h.Attribute("tabindex", "0"), - h.Class("flex-1 columns-2 gap-2 overflow-y-auto overflow-x-hidden px-3 scrollbar-thin relative"), - h.OnMount(Mount.mapMessage(TrackGifScroll(), toMessage)), + centered(h, [ + h.div( + [h.Class("flex flex-col gap-1")], + [ + h.span([h.Class("font-medium text-fg text-sm")], [copy.title]), + copy.description === undefined + ? h.empty + : h.span([h.Class("text-muted-fg text-xs")], [copy.description]), + ], + ), + retryButton(h, toMessage(Message.ClickedRetryFetchGifs())), + ]), ], - model.gifs.length === 0 - ? [emptyRow(h, model.isLoading ? loader(h) : "No GIFs found")] - : [ - ...model.gifs.map((gif) => - h.keyed("div")( - gif.slug, - [ - h.Role("row"), - h.Attribute("data-rac", ""), - h.Attribute("tabindex", "-1"), - h.Class( - "relative mb-2 cursor-pointer break-inside-avoid overflow-hidden rounded-md bg-muted/60 outline-none transition-all hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary ", - ), - h.Attribute("style", `aspect-ratio: ${gif.width} / ${gif.height};`), - h.OnClick(toMessage(Message.ClickedGif({ slug: gif.slug }))), - h.OnMouseEnter(toMessage(Message.HoveredGif({ slug: gif.slug }))), - h.OnMouseLeave(toMessage(Message.HoveredGif({ slug: null }))), - ], - [ - h.div( - [h.Role("gridcell"), h.Attribute("aria-colindex", "1")], - [ - h.img([ - h.Attribute("src", model.hoveredSlug === gif.slug ? gif.webpUrl : gif.jpgUrl), - h.Attribute("alt", gif.title), - h.Class("h-full w-full object-cover"), - h.Attribute("loading", "lazy"), - h.Attribute("draggable", "false"), - ]), - ], - ), - ], - ), - ), - h.div( - [h.Class(model.isLoadingMore ? "flex justify-center py-4" : "h-0")], - model.isLoadingMore - ? [h.div([h.Class("size-5 animate-spin rounded-full border-2 border-fg/20 border-t-fg/60")])] - : [], + ) +} + +/** One GIF: a real button (Tab reaches it, Enter and Space pick it), animating while hovered or focused. */ +const gifButton = <M>(h: HtmlBuilder<M>, model: Model, gif: Gif, toMessage: (message: Message) => M): Html => + h.keyed("li")( + gif.slug, + [h.Class("mb-2 break-inside-avoid")], + [ + h.button( + [ + h.Attribute("type", "button"), + h.AriaLabel(gif.title || "GIF"), + h.Class( + "relative block w-full cursor-pointer overflow-hidden rounded-md bg-muted/60 outline-none transition-all hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary", ), + h.Attribute("style", `aspect-ratio: ${gif.width} / ${gif.height};`), + h.OnClick(toMessage(Message.ClickedGif({ slug: gif.slug }))), + h.OnMouseEnter(toMessage(Message.HoveredGif({ slug: gif.slug }))), + h.OnMouseLeave(toMessage(Message.HoveredGif({ slug: null }))), + h.OnFocus(toMessage(Message.HoveredGif({ slug: gif.slug }))), + h.OnBlur(toMessage(Message.HoveredGif({ slug: null }))), + ], + [ + h.img([ + h.Attribute("src", model.hoveredSlug === gif.slug ? gif.webpUrl : gif.jpgUrl), + h.Attribute("alt", ""), + h.Class("h-full w-full object-cover"), + h.Attribute("loading", "lazy"), + h.Attribute("draggable", "false"), + ]), ], + ), + ], + ) + +/** The pager under the GIFs: a spinner while the next page loads, its failure with a retry. */ +const moreFooter = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => + Match.valueTags(model.more, { + Idle: () => h.empty, + Exhausted: () => h.empty, + Loading: () => + h.div( + [h.Class("flex justify-center py-4")], + [h.div([h.Class("size-5 animate-spin rounded-full border-2 border-fg/20 border-t-fg/60")])], + ), + Failed: ({ reason }) => + h.div( + [h.Class("flex flex-col items-center gap-2 py-4 text-muted-fg text-xs"), h.Role("alert")], + [failureCopy(reason).title, retryButton(h, toMessage(Message.ClickedRetryFetchGifs()))], + ), + }) + +const gifList = <M>( + h: HtmlBuilder<M>, + model: Model, + page: GifPage, + toMessage: (message: Message) => M, +): ReadonlyArray<Html> => + page.gifs.length === 0 + ? [centered(h, ["No GIFs found"])] + : [ + h.ul( + [h.Class("columns-2 gap-2"), h.AriaLabel("GIF results")], + page.gifs.map((gif) => gifButton(h, model, gif, toMessage)), + ), + moreFooter(h, model, toMessage), + ] + +/** `GifPickerGrid`: the results, or their loading or failure state. */ +const results = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => + h.div( + [ + h.Class("relative flex-1 overflow-y-auto overflow-x-hidden px-3 scrollbar-thin"), + h.Attribute("aria-busy", AsyncData.isPending(model.results) ? "true" : "false"), + h.OnMount(Mount.mapMessage(TrackGifScroll(), toMessage)), + ], + AsyncData.matchData(model.results, { + onEmpty: () => [centered(h, [loader(h)])], + onFailure: (reason) => [failureState(h, reason, toMessage)], + onData: (page) => gifList(h, model, page, toMessage), + }), ) const content = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => h.div( - [h.Class("flex h-[420px] w-[400px] flex-col overflow-hidden rounded-lg border border-fg/15 bg-overlay shadow-lg")], + [ + h.Class( + "flex h-[420px] w-[400px] flex-col overflow-hidden rounded-lg border border-fg/15 bg-overlay shadow-lg", + ), + ], [ h.div( - [h.Class("px-3 pt-3 pb-2"), h.OnMount(Mount.mapMessage(FocusGifSearch(), toMessage))], + [h.Class("px-3 pt-3 pb-2")], [ searchField( h, @@ -148,7 +237,7 @@ const content = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Messag ], ), model.query === "" ? categoryChips(h, model, toMessage) : h.empty, - grid(h, model, toMessage), + results(h, model, toMessage), h.div( [h.Class("flex items-center justify-end border-t border-fg/10 px-3 py-1.5")], [ @@ -166,8 +255,6 @@ const content = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Messag ], ) -export const searchInputId = (model: Model) => searchFieldIds(`${model.id}-search`).input - export const view = <M>( h: HtmlBuilder<M>, model: Model, diff --git a/apps/web-foldkit/src/overlay/modal/set-status-view.ts b/apps/web-foldkit/src/overlay/modal/set-status-view.ts index f3014f9d8..6dc21cec1 100644 --- a/apps/web-foldkit/src/overlay/modal/set-status-view.ts +++ b/apps/web-foldkit/src/overlay/modal/set-status-view.ts @@ -25,8 +25,10 @@ import { /** The set-status modal's view (legacy `SetStatusModal` markup). */ -const toCustomDateMessage = (message: DatePicker.Message): Message => Message.GotCustomDateMessage({ message }) -const toEmojiPickerMessage = (message: EmojiDialog.Message): Message => Message.GotEmojiPickerMessage({ message }) +const toCustomDateMessage = (message: DatePicker.Message): Message => + Message.GotCustomDateMessage({ message }) +const toEmojiPickerMessage = (message: EmojiDialog.Message): Message => + Message.GotEmojiPickerMessage({ message }) /** Legacy `<DatePicker value onChange minValue><DatePickerTrigger /></DatePicker>`. */ const datePicker = (h: HtmlBuilder<Message>, model: DatePicker.Model): ReadonlyArray<Html> => [ @@ -48,7 +50,10 @@ const customDateTimeRow = (h: HtmlBuilder<Message>, model: Model): Html => [ h.div( [h.Class("flex flex-1 flex-col gap-1")], - [label(h, { className: "text-muted-fg text-xs" }, ["Date"]), ...datePicker(h, model.customDate)], + [ + label(h, { className: "text-muted-fg text-xs" }, ["Date"]), + ...datePicker(h, model.customDate), + ], ), h.div( [h.Class("flex flex-col gap-1")], @@ -118,7 +123,11 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, [ textField( h, - { id: `${ID}-message`, value: model.message, onInput: (value) => Message.ChangedMessage({ value }) }, + { + id: `${ID}-message`, + value: model.message, + onInput: (value) => Message.ChangedMessage({ value }), + }, (field) => [ field.label(["Status"]), h.div( @@ -127,16 +136,26 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, EmojiDialog.view(h, model.emojiPicker, { toMessage: toEmojiPickerMessage, customEmojis: model.customEmojis, + // The popover sits beside its trigger, never inside the <button>. toTrigger: (attributes, overlay) => - button( - h, - { - intent: "outline", - size: "md", - className: "min-w-12 text-lg", - attributes: [h.AriaLabel("Pick an emoji"), ...attributes], - }, - [model.emoji || "😊", overlay], + h.div( + [h.Class("contents")], + [ + button( + h, + { + intent: "outline", + size: "md", + className: "min-w-12 text-lg", + attributes: [ + h.AriaLabel("Pick an emoji"), + ...attributes, + ], + }, + [model.emoji || "😊"], + ), + overlay, + ], ), }), field.input({ @@ -165,7 +184,8 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, { id: `${ID}-pause`, isSelected: model.pauseNotifications, - onChange: (isSelected) => Message.ToggledPauseNotifications({ isSelected }), + onChange: (isSelected) => + Message.ToggledPauseNotifications({ isSelected }), }, "Pause notifications", ), @@ -189,7 +209,11 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, ? [ button( h, - { intent: "danger", onPress: Message.ClickedClear(), isDisabled: model.isSubmitting }, + { + intent: "danger", + onPress: Message.ClickedClear(), + isDisabled: model.isSubmitting, + }, ["Clear status"], ), ] @@ -201,7 +225,11 @@ export const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, button(h, { intent: "outline", onPress: Message.ClickedCancel() }, ["Cancel"]), button( h, - { intent: "primary", onPress: Message.ClickedSave(), isDisabled: isSaveDisabled(model) }, + { + intent: "primary", + onPress: Message.ClickedSave(), + isDisabled: isSaveDisabled(model), + }, [model.isSubmitting ? "Saving..." : "Save"], ), ], diff --git a/apps/web-foldkit/src/picker-popover/popover.ts b/apps/web-foldkit/src/picker-popover/popover.ts index 962ab413e..9c4d14d0d 100644 --- a/apps/web-foldkit/src/picker-popover/popover.ts +++ b/apps/web-foldkit/src/picker-popover/popover.ts @@ -56,7 +56,11 @@ export interface PopoverInputs<M> { readonly isOpen: boolean readonly ariaLabel: string readonly toMessage: (event: PopoverEvent) => M - /** The DialogTrigger's child: spread `attributes` on the button and put `overlay` last. */ + /** + * The DialogTrigger's child: spread `attributes` on the button and render `overlay` as its sibling + * (e.g. `h.div([h.Class("contents")], [button, overlay])`), never inside the `<button>`: interactive + * content inside a button is invalid HTML and its clicks would bubble to the trigger. + */ readonly toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html readonly content: () => ReadonlyArray<Html> } From 744101aec42e60dc1267ed92b700e89593477ceb Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:27:03 +0200 Subject: [PATCH 35/52] wip(web): root/shell/platform audit fixes (in progress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/app/auth.ts | 57 +- apps/web-foldkit/src/app/command.ts | 65 +- apps/web-foldkit/src/app/message.ts | 35 +- apps/web-foldkit/src/app/model.ts | 122 ++- .../web-foldkit/src/app/notification-sinks.ts | 33 +- apps/web-foldkit/src/app/subscription.ts | 85 +- apps/web-foldkit/src/app/view.ts | 157 ++-- apps/web-foldkit/src/auth/return-to.ts | 18 + apps/web-foldkit/src/data/storage.ts | 21 +- apps/web-foldkit/src/entry.ts | 3 +- apps/web-foldkit/src/main.ts | 783 +++++++++--------- .../src/notification-sound.test.ts | 4 +- .../src/overlay/modal/create-dm.ts | 10 +- .../src/overlay/modal/create-organization.ts | 10 +- .../src/overlay/modal/delete-channel.ts | 2 +- .../src/overlay/modal/delete-workspace.ts | 3 +- .../src/overlay/modal/new-channel.ts | 6 +- apps/web-foldkit/src/overlay/out-message.ts | 9 +- apps/web-foldkit/src/overlay/toaster.test.ts | 2 + apps/web-foldkit/src/page/auth/layout.ts | 2 +- apps/web-foldkit/src/page/auth/scene.test.ts | 4 +- .../web-foldkit/src/page/auth/sign-in/view.ts | 2 +- .../integrations/story.test.ts | 3 +- .../page/channel-settings/redirect/index.ts | 6 +- .../src/page/chat/channel/index.ts | 4 +- .../web-foldkit/src/page/chat/channel/page.ts | 6 +- apps/web-foldkit/src/page/home/scene.test.ts | 5 +- apps/web-foldkit/src/page/home/story.test.ts | 7 +- apps/web-foldkit/src/page/join/story.test.ts | 4 +- apps/web-foldkit/src/page/join/update.ts | 2 +- .../src/page/my-settings/desktop/view.ts | 6 +- .../linked-accounts/linked-accounts.test.ts | 5 +- .../my-settings/linked-accounts/story.test.ts | 3 +- .../my-settings/linked-accounts/update.ts | 4 +- .../src/page/onboarding/story.test.ts | 4 +- .../web-foldkit/src/page/onboarding/update.ts | 2 +- apps/web-foldkit/src/page/out-message.ts | 6 +- apps/web-foldkit/src/page/root/index.ts | 59 +- apps/web-foldkit/src/page/root/model.ts | 3 +- apps/web-foldkit/src/page/root/story.test.ts | 20 +- .../page/select-organization/scene.test.ts | 7 +- .../page/select-organization/story.test.ts | 4 +- .../src/page/select-organization/update.ts | 12 +- .../chat-sync-connection/scene.test.ts | 5 +- .../chat-sync-connection/story.test.ts | 3 +- .../src/page/settings/chat-sync/scene.test.ts | 5 +- .../src/page/settings/chat-sync/story.test.ts | 5 +- .../src/page/settings/debug/index.ts | 4 +- .../settings/general/general.scene.test.ts | 3 +- .../src/page/settings/general/story.test.ts | 3 +- .../src/page/settings/general/update.ts | 4 +- .../integrations/detail/scene.test.ts | 7 +- .../settings/integrations/detail/update.ts | 6 +- .../settings/integrations/index/scene.test.ts | 3 +- .../settings/integrations/index/update.ts | 4 +- .../integrations/installed/scene.test.ts | 3 +- .../integrations/installed/story.test.ts | 3 +- .../settings/integrations/installed/update.ts | 4 +- apps/web-foldkit/src/platform/desktop-auth.ts | 96 ++- apps/web-foldkit/src/platform/desktop-menu.ts | 34 +- apps/web-foldkit/src/platform/index.ts | 54 +- .../src/platform/presence/message.ts | 11 +- .../src/platform/presence/model.ts | 26 +- .../src/platform/presence/update.ts | 68 +- apps/web-foldkit/src/platform/rivet.ts | 23 - apps/web-foldkit/src/platform/updates.test.ts | 2 +- apps/web-foldkit/src/platform/updates.ts | 203 +++-- apps/web-foldkit/src/redirect.ts | 35 +- apps/web-foldkit/src/route.test.ts | 35 +- apps/web-foldkit/src/route.ts | 211 +++-- apps/web-foldkit/src/rpc.ts | 4 +- apps/web-foldkit/src/shell/app-shell.ts | 14 +- .../web-foldkit/src/shell/channels-sidebar.ts | 112 ++- .../src/shell/channels-sidebar/actions.ts | 96 +-- .../src/shell/channels-sidebar/hints.ts | 34 +- .../src/shell/channels-sidebar/items.ts | 12 +- .../src/shell/channels-sidebar/menu-update.ts | 207 ++--- .../src/shell/channels-sidebar/menus.ts | 34 +- .../src/shell/channels-sidebar/model.ts | 47 +- .../channels-sidebar/sidebar.story.test.ts | 184 ++++ .../shell/channels-sidebar/subscriptions.ts | 12 - .../src/shell/channels-sidebar/view.ts | 21 +- apps/web-foldkit/src/shell/context.ts | 9 +- apps/web-foldkit/src/shell/layouts.ts | 31 +- apps/web-foldkit/src/shell/menus.ts | 182 ++-- apps/web-foldkit/src/shell/mobile.ts | 39 +- apps/web-foldkit/src/shell/model.ts | 7 +- .../src/shell/notifications.test.ts | 2 +- apps/web-foldkit/src/shell/notifications.ts | 19 +- apps/web-foldkit/src/shell/sidebars.ts | 110 ++- apps/web-foldkit/src/shell/subscription.ts | 25 +- apps/web-foldkit/src/shell/update.ts | 298 +++---- apps/web-foldkit/src/test/root-fixtures.ts | 5 +- apps/web-foldkit/src/theme.test.ts | 12 +- 94 files changed, 2346 insertions(+), 1635 deletions(-) delete mode 100644 apps/web-foldkit/src/platform/rivet.ts create mode 100644 apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts 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>()("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<Auth, unknown, HazelAuthClient> = 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<Auth, unknown, HazelAuthClient> = read.pipe( - Effect.tapError((error) => Effect.logWarning("[auth] session check failed", error)), - Effect.retry({ schedule: retryUnavailable, times: 3 }), +export const readSession: Effect.Effect<SessionAnswer, SessionCheckError, HazelAuthClient> = ( + 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>()("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<void, SignOutError, HazelAuthClient> = 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..671d55156 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,30 +40,39 @@ 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. */ export const DeliverNotifications = Command.define("DeliverNotifications", { args: { ids: Schema.Array(NotificationId) }, messages: [Message.CompletedDeliverNotifications], - execute: ({ ids }) => deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), + execute: ({ ids }) => + deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), }) /** 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 +94,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 +121,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/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/model.ts b/apps/web-foldkit/src/app/model.ts index 215174e8e..614076328 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -1,44 +1,81 @@ -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,24 +89,52 @@ 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<Extract<OrgState, { _tag: "Loaded" }>> => + 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, @@ -81,6 +146,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 fc1348465..1f9a0677b 100644 --- a/apps/web-foldkit/src/app/notification-sinks.ts +++ b/apps/web-foldkit/src/app/notification-sinks.ts @@ -1,5 +1,5 @@ import { ChannelId, MessageId, type NotificationId, type UserId } from "@hazel/schema" -import { Effect, Option, Schema, Stream } from "effect" +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 { @@ -11,12 +11,19 @@ import { 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>()("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 @@ -35,10 +42,11 @@ export const wireNotificationSinks = ({ settings, currentChannelId, sessionStartMs, -}: SinkContext): Stream.Stream<never> => - Stream.callback<never>(() => +}: SinkContext): Stream.Stream<never, never, NotificationSinks> => + Stream.callback<never, never, NotificationSinks>(() => Effect.acquireRelease( - Effect.sync(() => { + NotificationSinks.use(({ soundSink, nativeSink }) => + Effect.sync(() => { const sessionStartTime = new Date(sessionStartMs) const cleanupPriming = notificationSoundManager.initPriming() notificationSoundManager.setDependencies({ @@ -72,6 +80,7 @@ export const wireNotificationSinks = ({ }) return cleanupPriming }), + ), (cleanupPriming) => Effect.sync(cleanupPriming), ).pipe(Effect.flatMap(() => Effect.never)), ) @@ -81,7 +90,8 @@ const decodeChannelId = Schema.decodeUnknownOption(ChannelId) /** The provider's `recentNotifications` effect: oldest first, with the rows the sinks describe. */ export const deliverNotifications = (ids: ReadonlyArray<NotificationId>) => - Effect.sync(() => { + Effect.flatMap(Clock.currentTimeMillis, (receivedAtMs) => + Effect.sync(() => { const events = ids.flatMap((id): ReadonlyArray<NotificationEvent> => { const notification = notificationCollection.state.get(id) if (notification === undefined) return [] @@ -96,7 +106,8 @@ export const deliverNotifications = (ids: ReadonlyArray<NotificationId>) => channelCollection.state.get(id), ), ) - return [{ id: notification.id, notification, message, author, channel, receivedAt: Date.now() }] + return [{ id: notification.id, notification, message, author, channel, receivedAt: receivedAtMs }] }) notificationOrchestrator.enqueue([...events].reverse()) - }) + }), + ) diff --git a/apps/web-foldkit/src/app/subscription.ts b/apps/web-foldkit/src/app/subscription.ts index 4eafe8a7d..394a8765d 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, Effect, 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<Model, Message>()((entry) => ({ systemTheme: Subscription.persistentEntry( @@ -32,17 +42,32 @@ const rootSubscriptions = Subscription.make<Model, Message>()((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,8 @@ const rootSubscriptions = Subscription.make<Model, Message>()((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 +136,7 @@ const rootSubscriptions = Subscription.make<Model, Message>()((entry) => ({ (rows) => { const member = rows[0] return Message.UpdatedMember({ + organizationId, member: member ? { id: member.id, role: member.role } : null, }) }, @@ -119,14 +145,10 @@ const rootSubscriptions = Subscription.make<Model, Message>()((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<Model, Message>()((entry) => ({ +const notificationSubscriptions = Subscription.make<Model, Message, NotificationSinks>()((entry) => ({ notificationSinks: entry( { userId: Schema.NullOr(UserId), @@ -136,9 +158,9 @@ const notificationSubscriptions = Subscription.make<Model, Message>()((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 +173,7 @@ const notificationSubscriptions = Subscription.make<Model, Message>()((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 +196,7 @@ const overlaySubscriptions = Subscription.make<Model, Message>()((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 +221,15 @@ const shellSubscriptions = Subscription.lift(ShellSubscription.subscriptions)<Mo }) /** `PresenceProvider` runs inside the loaded `$orgSlug` layout, once `user.me` answered. */ -const presenceUserIdOf = (model: Model): UserId | null => { - 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)<Model, Message>({ 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 }), }) @@ -246,9 +267,3 @@ export const subscriptions = Subscription.aggregate( updateSubscriptions, desktopMenuSubscriptions, ) - -/** App-lifetime managed resources (the Rivet client). */ -export const managedResources = ManagedResource.lift(Platform.managedResources)<Model, Message>({ - 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..8be8517d0 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -1,37 +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 { - appLoader, - organizationNotFound, - type ChannelSettingsTab, - channelSettingsLayout, - contentColumnLayout, - sectionLayout, -} from "../shell/layouts" +import { appError, appLoader, channelSettingsLayout, 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, resolvedThemeOf, 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. const toShellMessage = (message: Shell.Message): Message => ({ _tag: "GotShellMessage", message }) const toChannelsSidebarMessage = (message: ChannelsSidebar.Message) => toShellMessage({ _tag: "GotChannelsSidebarMessage", message }) -const toUserMenuMessage = (message: Menu.Message) => - toShellMessage(Shell.Message.GotUserMenuMessage({ message })) +const toUserMenuMessage = (message: Menu.Message) => toShellMessage(Shell.Message.GotUserMenuMessage({ message })) const toOrgSwitcherMessage = (message: Menu.Message) => toShellMessage(Shell.Message.GotOrgSwitcherMessage({ message })) const toMobileSidebarMessage = (message: ModalKit.Message) => @@ -50,17 +52,22 @@ const toCommandPaletteMessage = (message: CommandPalette.Message): Message => ({ 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,31 +78,16 @@ const pageBody = (model: Model, h: HtmlBuilder<Message>, 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<Message>): Html => { - const route = model.route + const { route } = model const section = orgSectionOf(route) if (section === "Settings") return sectionLayout( h, - hasContentColumn(route) - ? contentColumnLayout(h, pageBody(model, h, true)) - : pageBody(model, h, true), + hasContentColumn(route) ? contentColumnLayout(h, pageBody(model, h, true)) : pageBody(model, h, true), ) - if (section === "MySettings" || section === "Notifications") - return sectionLayout(h, pageBody(model, h, true)) + if (section === "MySettings" || section === "Notifications") return sectionLayout(h, pageBody(model, h, true)) const tab = channelSettingsTabOf(route) if (tab !== undefined && "channelId" in route) return channelSettingsLayout( @@ -105,8 +97,8 @@ const sectionBody = (model: Model, h: HtmlBuilder<Message>): 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), ) @@ -133,16 +125,10 @@ const secondarySidebar = (model: Model, h: HtmlBuilder<Message>, 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 +140,8 @@ const loader = (h: HtmlBuilder<Message>, isStalled: boolean): Html => const appRoot = (h: HtmlBuilder<Message>, model: Model, children: ReadonlyArray<Html>): Html => h.div([h.Id("app")], [toaster(h, model), ...children]) -const body = (model: Model, h: HtmlBuilder<Message>): 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<Message>, data: ShellData): Html => { + const context = shellContextOf(model, data) return orgShell(h, context, { page: sectionBody(model, h), secondarySidebar: secondarySidebar(model, h, context), @@ -187,7 +159,7 @@ const body = (model: Model, h: HtmlBuilder<Message>): Html => { }), ], mobile: { - isMobile: model.shell.isMobile, + isMobile: model.isMobile, isSidebarOpen: model.shell.isSidebarOpen, onMenu: h.OnClick(openedMobileSidebar), toSheetMessage: toMobileSidebarMessage, @@ -195,7 +167,52 @@ const body = (model: Model, h: HtmlBuilder<Message>): Html => { }) } +/** `$orgSlug/layout.tsx`: a loader while `useOrganization()` loads the route's organization. */ +const orgBody = (model: Model, h: HtmlBuilder<Message>, 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<Message>): 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<Message>): Document => ({ - title: "Hazel Chat", + title: titleOf(model.route), body: body(model, h), }) 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<void> = } }) +/** The remembered path, left in place: it applies until a redirect actually goes there (`forgetReturnTo`). */ +export const readReturnTo: Effect.Effect<string | null> = 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<void> = 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<string | null> = Effect.sync(() => { try { diff --git a/apps/web-foldkit/src/data/storage.ts b/apps/web-foldkit/src/data/storage.ts index 537e630a8..45830acce 100644 --- a/apps/web-foldkit/src/data/storage.ts +++ b/apps/web-foldkit/src/data/storage.ts @@ -1,4 +1,4 @@ -import { Effect, Option, type Schema } from "effect" +import { Effect, Option, Schema } from "effect" import * as KeyValueStore from "effect/persistence/KeyValueStore" import { layer as platformStorageLayer } from "~/lib/platform-storage/platform-key-value-store" @@ -14,7 +14,18 @@ export const readStored = <S extends Schema.Constraint>(key: string, schema: S) Effect.catch(() => Effect.succeed(Option.none<S["Type"]>())), ) -export const writeStored = <S extends Schema.Constraint>(key: string, schema: S, value: S["Type"]) => - KeyValueStore.KeyValueStore.use((store) => - KeyValueStore.toSchemaStore(store, schema).set(key, value), - ).pipe(Effect.provide(platformStorageLayer), Effect.ignore) +/** A setting could not be saved: storage is full or blocked (private mode), or the value did not encode. */ +export class StorageWriteError extends Schema.TaggedError<StorageWriteError>()("StorageWriteError", { + key: Schema.String, + cause: Schema.Defect(), +}) {} + +export const writeStored = <S extends Schema.Constraint>( + key: string, + schema: S, + value: S["Type"], +) => + KeyValueStore.KeyValueStore.use((store) => KeyValueStore.toSchemaStore(store, schema).set(key, value)).pipe( + Effect.provide(platformStorageLayer), + Effect.mapError((cause) => new StorageWriteError({ key, cause })), + ) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index 68ab8a08a..b3d74b087 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -8,7 +8,7 @@ import { initTauri } from "~/lib/tauri" import { isDesktop } from "./platform/desktop" import { desktopSessionBearer } from "./platform/desktop-auth" import { galleryComponentOf, startGallery } from "./gallery/boot" -import { Flags, flags, init, managedResources, Message, Model, subscriptions, update, view } from "./main" +import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" import { ResourcesLive } from "./rpc" const container = document.getElementById("app")! @@ -22,7 +22,6 @@ const startApplication = () => update, view, subscriptions, - managedResources, resources: ResourcesLive, container, routing: { diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index 33b26df08..dc85ab5c4 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -1,44 +1,59 @@ +import type { OrganizationId } from "@hazel/schema" import { Clock, Effect, Match, Option, Schema } from "effect" -import { Command, type Runtime, Update } from "foldkit" +import { AsyncData, type Runtime, Update } from "foldkit" import { UrlRequest } from "foldkit/navigation" import { modifyFields } from "foldkit/struct" import { toString as urlToString, type Url } from "foldkit/url" +import { commonMessage, type CommonReason, isRetryableReason } from "~/lib/error-messages" import { ApplyTheme, CheckSession, - Reload, - RetryCheckSession, + DeliverNotifications, FetchCurrentUser, - RetryFetchCurrentUser, LoadExternal, NavigateInternal, - DeliverNotifications, + Reload, ReplaceUrl, + RetryCheckSession, + RetryFetchCurrentUser, SaveSoundSettings, SaveThemePreference, SignOut, } from "./app/command" import { Message } from "./app/message" -import { Model, resolvedThemeOf, sharedOf, shellContextOf } from "./app/model" +import { + authOf, + MemberData, + Model, + OrgState, + orgStateFor, + resolvedThemeOf, + Session, + sharedOf, + shellContextOf, +} from "./app/model" +import { MOBILE_QUERY } from "./app/subscription" import { toPageMessage } from "./app/view" +import { errorToast, errorToastOf } from "./data/actions" +import { loadSoundSettings, SoundSettings } from "./notification-sound" import * as CommandPalette from "./overlay/command-palette" -import type { Return as CommandPaletteReturn } from "./overlay/command-palette/update" import type { LayoutHotkeyActionId } from "./overlay/hotkeys" import * as Modal from "./overlay/modal" -import * as Platform from "./platform" -import type { DesktopMenuItem } from "./platform/desktop-menu" -import * as Updates from "./platform/updates" +import { ModalOutMessage } from "./overlay/out-message" import * as Toasts from "./overlay/toaster" +import type { ToastAction } from "./overlay/toasts" import { can } from "./page/contract" import { PageOutMessage } from "./page/out-message" -import { errorToast } from "./data/actions" -import { ModalOutMessage } from "./overlay/out-message" -import { enterRoute, informShared, type PageTransition, updatePage } from "./page/registry" +import { enterRoute, informShared, type PageSlot, type PageTransition, updatePage } from "./page/registry" +import * as Platform from "./platform" +import type { DesktopMenuItem } from "./platform/desktop-menu" +import * as Updates from "./platform/updates" import { authRedirect, routeRedirect } from "./redirect" import { AppRoute, hrefOf, orgSlugOf, urlToAppRoute } from "./route" +import type { Member, Organization } from "./session" import type { Resources } from "./rpc" -import { loadSoundSettings, SoundSettings } from "./notification-sound" -import { Message as ShellMessage } from "./shell/model" +import { loadIsCreateChannelHintDismissed } from "./shell/channels-sidebar" +import { Message as ShellMessage, type Model as ShellModel } from "./shell/model" import * as Shell from "./shell/update" import { loadThemePreference, @@ -50,16 +65,20 @@ import { export { Message } from "./app/message" export { Model } from "./app/model" -export { managedResources, subscriptions } from "./app/subscription" +export { subscriptions } from "./app/subscription" export { view } from "./app/view" -/** Read before the first render, so a stored theme never flashes the default (`Atom.kvs` reads sync). */ +/** Read before the first render, so a stored theme, the viewport or a dismissed hint never flash. */ export const Flags = Schema.Struct({ themePreference: ThemePreference, systemTheme: ResolvedTheme, soundSettings: SoundSettings, - /** `sessionStartTimeAtom`: notifications from before this load never play. */ + /** `sessionStartTimeAtom`: notifications from before this load never play; also the clock's first value. */ sessionStartMs: Schema.Number, + /** `useSidebar().isMobile` at load: the mobile layout renders from the first frame. */ + isMobile: Schema.Boolean, + /** `useFeatureHint("create-channel")`. */ + isCreateChannelHintDismissed: Schema.Boolean, }) export type Flags = typeof Flags.Type @@ -68,287 +87,349 @@ export const flags: Effect.Effect<Flags> = Effect.all({ systemTheme: Effect.sync(resolveSystemTheme), soundSettings: loadSoundSettings, sessionStartMs: Clock.currentTimeMillis, + isMobile: Effect.sync(() => globalThis.matchMedia?.(MOBILE_QUERY).matches ?? false), + isCreateChannelHintDismissed: loadIsCreateChannelHintDismissed, }) +/** Production builds skip the dev-only routes. Read once here, never inside `update`. */ +const IS_PROD = import.meta.env.PROD + +/** Automatic retries of a session check or `user.me` before the error screen waits for the user. */ +export const MAX_AUTO_RETRIES = 3 + type Return = Update.Return<Model, Message, Resources> type Step = Update.Step<Model, Message, Resources> +const unchanged: Step = (model) => ({ model }) + +const command = + (...commands: NonNullable<Return["commands"]>): Step => + (model) => ({ model, commands }) + +const setModel = + (f: (model: Model) => Model): Step => + (model) => ({ model: f(model) }) + // CHILDREN -const foldOverlay = - <Child, ChildMessage>( - write: (model: Model, child: Child) => Model, - toParentMessage: (message: ChildMessage) => Message, - ) => - (model: Model, result: Update.Return<Child, ChildMessage>): Return => ({ - model: write(model, result.model), - commands: Command.mapMessages(result.commands, toParentMessage), - }) +/** Each child folds through one lens; the input is the child entry point to run (open, push, update...). */ -const withToasts = foldOverlay( - (model, toasts: Toasts.Model) => modifyFields(model, { toasts: () => toasts }), - (message: Toasts.Message) => Message.GotToastsMessage({ message }), -) +const foldToasts = Update.foldChild({ + update: (toasts: Toasts.Model, step: (toasts: Toasts.Model) => Update.Return<Toasts.Model, Toasts.Message>) => + step(toasts), + read: (model: Model) => Option.some(model.toasts), + write: (model, toasts) => modifyFields(model, { toasts: () => toasts }), + toParentMessage: (message: Toasts.Message) => Message.GotToastsMessage({ message }), +}) -/** Completed overlay actions: follow the link, then show the toast (legacy `onSuccess` order). */ -const completedOverlay = (model: Model, href: string | null, toast: Toasts.ToastRequest | null): Return => { - const toasted = toast === null ? { model } : withToasts(model, Toasts.push(model.toasts, toast)) - return { - model: toasted.model, - commands: [...(toasted.commands ?? []), ...(href === null ? [] : [NavigateInternal({ url: href })])], - } -} +const showToast = (toast: Toasts.ToastRequest): Step => foldToasts((toasts) => Toasts.push(toasts, toast)) -const followModalOutMessage = (model: Model, outMessage: Option.Option<ModalOutMessage>): Return => - Option.match(outMessage, { - onNone: () => ({ model }), - onSome: ModalOutMessage.match<Return>({ - Closed: () => ({ model }), - Completed: ({ href, toast }) => completedOverlay(model, href, toast), - RequestedToast: ({ toast }) => withToasts(model, Toasts.push(model.toasts, toast)), - }), +const showToastIf = (toast: Toasts.ToastRequest | null | undefined): Step => + toast === null || toast === undefined ? unchanged : showToast(toast) + +const navigateTo = (route: AppRoute, options: { readonly replace?: boolean } = {}): Step => + command(options.replace ? ReplaceUrl({ url: hrefOf(route) }) : NavigateInternal({ url: hrefOf(route) })) + +const foldUpdates = Update.foldChild({ + update: (updates: Updates.Model, step: (updates: Updates.Model) => Updates.Return) => step(updates), + read: (model: Model) => Option.some(model.desktopUpdate), + write: (model, desktopUpdate) => modifyFields(model, { desktopUpdate: () => desktopUpdate }), + toParentMessage: (message: Updates.Message) => Message.GotUpdatesMessage({ message }), + foldOutMessage: Updates.OutMessage.match<Step>({ RequestedToast: ({ toast }) => showToast(toast) }), +}) + +/** An update step, its toast, and the pages that read `Shared.desktopUpdate`. */ +const stepUpdates = (step: (updates: Updates.Model) => Updates.Return): Step => + Update.combine([foldUpdates(step), informPage]) + +/** The toaster; a toast's action button reports what it asks for (the update toast's "Install & Restart"). */ +const foldToastsWithAction = Update.foldChild({ + update: (toasts: Toasts.Model, message: Toasts.Message) => { + const { action, ...result } = Toasts.update(toasts, message) + return Update.withOutMessage(result, action) + }, + read: (model: Model) => Option.some(model.toasts), + write: (model, toasts) => modifyFields(model, { toasts: () => toasts }), + toParentMessage: (message: Toasts.Message) => Message.GotToastsMessage({ message }), + foldOutMessage: (action: ToastAction) => + Match.value(action).pipe( + Match.withReturnType<Step>(), + Match.when("InstallUpdate", () => stepUpdates(Updates.install)), + Match.exhaustive, + ), +}) + +const followModalOutMessage = (outMessage: ModalOutMessage): Step => + ModalOutMessage.match<Step>(outMessage, { + Closed: () => unchanged, + // Legacy `onSuccess` order: follow the link, then show the toast. + Completed: ({ route, toast }) => + Update.combine([route === null ? unchanged : navigateTo(route), showToastIf(toast)]), + RequestedToast: ({ toast }) => showToast(toast), }) -/** The modal slot after an open or update; a closing OutMessage already dropped the slot. */ -const withModal = (model: Model, transition: Modal.Transition): Return => { - const followed = followModalOutMessage(modifyFields(model, { modal: () => transition.model }), transition.outMessage) - return { - model: followed.model, - commands: [ - ...Command.mapMessages(transition.commands, (message) => Message.GotModalMessage({ message })), - ...(followed.commands ?? []), - ], - } -} +/** A closing OutMessage has already dropped the modal slot; the root still follows it. */ +const foldModal = Update.foldChild({ + update: (modal: Modal.Model, step: (modal: Modal.Model) => Modal.Transition) => { + const transition = step(modal) + return Update.withOutMessage( + { model: transition.model, commands: transition.commands ?? [] }, + Option.getOrUndefined(transition.outMessage), + ) + }, + read: (model: Model) => Option.some(model.modal), + write: (model, modal) => modifyFields(model, { modal: () => modal }), + toParentMessage: (message: Modal.Message) => Message.GotModalMessage({ message }), + foldOutMessage: followModalOutMessage, +}) -const withPlatform = (model: Model, result: Update.Return<Platform.Model, Platform.Message, Resources>): Return => ({ - model: modifyFields(model, { platform: () => result.model }), - commands: Command.mapMessages(result.commands, (child) => Message.GotPlatformMessage({ message: child })), +const openModal = + (request: Modal.ModalRequest): Step => + (model) => + foldModal(model, (modal) => Modal.open(modal, request, sharedOf(model))) + +const foldPlatform = Update.foldChild({ + update: ( + platform: Platform.Model, + step: (platform: Platform.Model) => Update.Return<Platform.Model, Platform.Message, Resources>, + ) => step(platform), + read: (model: Model) => Option.some(model.platform), + write: (model, platform) => modifyFields(model, { platform: () => platform }), + toParentMessage: (message: Platform.Message) => Message.GotPlatformMessage({ message }), }) -const withCommandPalette = (model: Model, result: CommandPaletteReturn): Return => { - const next = modifyFields(model, { commandPalette: () => result.model }) - const commands = Command.mapMessages(result.commands, (message) => Message.GotCommandPaletteMessage({ message })) - const followed = Option.match(Option.fromNullishOr(result.outMessage), { - onNone: (): Return => ({ model: next }), - onSome: CommandPalette.OutMessage.match<Return>({ - Completed: ({ href, toast }) => completedOverlay(next, href, toast), - RequestedModal: ({ modal }) => withModal(next, Modal.open(next.modal, modal, sharedOf(next))), - RequestedToast: ({ toast }) => withToasts(next, Toasts.push(next.toasts, toast)), - RequestedTheme: ({ preference }) => requestTheme(next, preference), - RequestedPresenceStatus: ({ status }) => withPlatform(next, Platform.pickPresenceStatus(next.platform, status)), +const foldCommandPalette = Update.foldChild({ + update: ( + palette: CommandPalette.Model, + step: (palette: CommandPalette.Model) => ReturnType<typeof CommandPalette.update>, + ) => step(palette), + read: (model: Model) => Option.some(model.commandPalette), + write: (model, commandPalette) => modifyFields(model, { commandPalette: () => commandPalette }), + toParentMessage: (message: CommandPalette.Message) => Message.GotCommandPaletteMessage({ message }), + foldOutMessage: (outMessage: CommandPalette.OutMessage) => + CommandPalette.OutMessage.match<Step>(outMessage, { + // The palette's links can carry a search (`?messageId=`), so they stay URLs. + Completed: ({ href, toast }) => + Update.combine([href === null ? unchanged : command(NavigateInternal({ url: href })), showToastIf(toast)]), + RequestedModal: ({ modal }) => openModal(modal), + RequestedToast: ({ toast }) => showToast(toast), + RequestedTheme: ({ preference }) => requestedTheme(preference), + RequestedPresenceStatus: ({ status }) => + foldPlatform((platform) => Platform.pickPresenceStatus(platform, status)), }), - }) - return { model: followed.model, commands: [...commands, ...(followed.commands ?? [])] } -} +}) + +const openCommandPalette = + (page: CommandPalette.Page): Step => + (model) => + foldCommandPalette(model, (palette) => CommandPalette.open(palette, page, sharedOf(model))) + +const foldShell = Update.foldChild({ + update: (shell: ShellModel, step: (shell: ShellModel) => Shell.ShellReturn) => step(shell), + read: (model: Model) => Option.some(model.shell), + write: (model, shell) => modifyFields(model, { shell: () => shell }), + // Literal wrapper: the constructor would re-validate every sidebar row payload. + toParentMessage: (message: ShellMessage): Message => ({ _tag: "GotShellMessage", message }), + foldOutMessage: (outMessage: PageOutMessage) => handleOutMessage(outMessage), +}) + +/** The shell's menus and sidebar follow the route, organization, user, role and viewport. */ +const informShell: Step = setModel((model) => + modifyFields(model, { shell: (shell) => Shell.informContext(shell, shellContextOf(model)) }), +) + +/** The page slot; its OutMessages are facts the root acts on (navigation, overlays, session). */ +const foldPage = Update.foldChild({ + update: (page: PageSlot | null, step: (page: PageSlot | null) => PageTransition) => { + const transition = step(page) + return Update.withOutMessage( + { model: transition.slot, commands: transition.commands ?? [] }, + Option.getOrUndefined(transition.outMessage), + ) + }, + read: (model: Model) => Option.some(model.page), + write: (model, page) => modifyFields(model, { page: () => page }), + toParentMessage: toPageMessage, + foldOutMessage: (outMessage: PageOutMessage) => handleOutMessage(outMessage), +}) + +const informPage: Step = (model) => foldPage(model, (page) => informShared(page, sharedOf(model))) + +const enteredRoute: Step = (model) => foldPage(model, (page) => enterRoute(page, model.route, sharedOf(model))) /** `useAppHotkey` handlers in `$orgSlug/layout.tsx`. */ -const pressedHotkey = (model: Model, actionId: LayoutHotkeyActionId): Return => { - const shared = sharedOf(model) - const openModal = (modal: Modal.ModalRequest) => withModal(model, Modal.open(model.modal, modal, shared)) - // Legacy passes `initialPage`, but the palette shows its own (reset) page: always home. - const openPalette = () => withCommandPalette(model, CommandPalette.open(model.commandPalette, "home", shared)) - return Match.value(actionId).pipe( - Match.when("commandPalette.open", openPalette), - Match.when("search.open", openPalette), - Match.when("channel.create", () => - can(shared, "channel.create") ? openModal({ _tag: "NewChannel" }) : { model }, +const pressedHotkey = (actionId: LayoutHotkeyActionId): Step => + Match.value(actionId).pipe( + Match.withReturnType<Step>(), + // Legacy passes `initialPage`, but the palette shows its own (reset) page: always home. + Match.when("commandPalette.open", () => openCommandPalette("home")), + Match.when("search.open", () => openCommandPalette("home")), + Match.when("channel.create", () => (model) => + can(sharedOf(model), "channel.create") ? openModal({ _tag: "NewChannel" })(model) : { model }, ), Match.when("dm.create", () => openModal({ _tag: "CreateDm" })), Match.when("invite.email", () => openModal({ _tag: "EmailInvite" })), Match.exhaustive, ) -} - -/** The update toast for a change of update state (legacy `TauriUpdateCheck`), if it needs one. */ -const updateToastOf = (previous: Updates.Model, next: Updates.Model): Toasts.ToastRequest | null => { - const id = "tauri-update" - switch (next._tag) { - case "Available": - return previous._tag === "Available" - ? null - : { - intent: "info", - title: `Update available: v${next.version}`, - description: next.body ?? "A new version is ready to install", - id, - isSticky: true, - action: { label: "Install & Restart", key: "InstallUpdate" }, - } - case "Downloading": - return { - intent: "loading", - title: - next.totalBytes === null - ? `Downloading update... ${(next.downloadedBytes / 1024 / 1024).toFixed(1)} MB` - : `Downloading update... ${Math.round((next.downloadedBytes / next.totalBytes) * 100)}%`, - description: null, - id, - } - case "Installing": - return { intent: "loading", title: "Installing update...", description: null, id } - case "Restarting": - return { intent: "loading", title: "Restarting...", description: null, id } - case "InstallFailed": - return previous._tag === "InstallFailed" - ? null - : { intent: "error", title: "Update failed", description: next.message, id } - default: - return null - } -} - -/** The update state after a check or install step, its toast, and the pages that read it. */ -const withUpdates = (model: Model, result: Update.Return<Updates.Model, Updates.Message>): Return => { - const next = modifyFields(model, { desktopUpdate: () => result.model }) - const toast = updateToastOf(model.desktopUpdate, result.model) - const toasted = toast === null ? { model: next } : withToasts(next, Toasts.push(next.toasts, toast)) - return Update.combine<Model, Message, Resources>(toasted.model, [ - (m) => ({ - model: m, - commands: [ - ...Command.mapMessages(result.commands, (message) => Message.GotUpdatesMessage({ message })), - ...(toasted.commands ?? []), - ], - }), - informPage, - ]) -} - -/** A toast's action button: the update toast's "Install & Restart". */ -const followToasts = (model: Model, result: ReturnType<typeof Toasts.update>): Return => { - const toasted = withToasts(model, result) - if (result.action !== "InstallUpdate") return toasted - const installing = withUpdates(toasted.model, Updates.install(toasted.model.desktopUpdate)) - return { model: installing.model, commands: [...(toasted.commands ?? []), ...(installing.commands ?? [])] } -} /** The desktop app menu (legacy `TauriMenuListener`). */ -const pressedDesktopMenuItem = (model: Model, item: DesktopMenuItem): Return => +const pressedDesktopMenuItem = (item: DesktopMenuItem): Step => Match.value(item).pipe( - Match.when("OpenSettings", () => { + Match.withReturnType<Step>(), + Match.when("OpenSettings", () => (model) => { const orgSlug = orgSlugOf(model.route) - return orgSlug === undefined - ? { model } - : { model, commands: [NavigateInternal({ url: hrefOf(AppRoute.MySettingsDesktop({ orgSlug })) })] } + return orgSlug === undefined ? { model } : navigateTo(AppRoute.MySettingsDesktop({ orgSlug }))(model) }), - Match.when("CheckForUpdates", () => withUpdates(model, Updates.check(model.desktopUpdate))), - Match.when("NewChannel", () => pressedHotkey(model, "channel.create")), - Match.when("Invite", () => pressedHotkey(model, "invite.email")), + Match.when("CheckForUpdates", () => stepUpdates(Updates.check)), + Match.when("NewChannel", () => pressedHotkey("channel.create")), + Match.when("Invite", () => pressedHotkey("invite.email")), Match.exhaustive, ) -/** Runs the OutMessage's consequence after `result`, keeping both sets of Commands. */ -const withCommands = (result: Return, outMessage: Option.Option<PageOutMessage>): Return => - Option.match(outMessage, { - onNone: () => result, - onSome: (found) => { - const followed = handleOutMessage(found)(result.model) - return { - model: followed.model, - commands: [...(result.commands ?? []), ...(followed.commands ?? [])], - } - }, - }) - -/** A page or the shell reported a fact; the root owns navigation and the overlays. */ +/** A page or the shell reported a fact; the root owns navigation, the overlays and the session. */ const handleOutMessage = (outMessage: PageOutMessage): Step => PageOutMessage.match<Step>(outMessage, { - RequestedNavigation: - ({ href, replace, toast }) => - (model) => { - const toasted = toast === undefined ? { model } : withToasts(model, Toasts.push(model.toasts, toast)) - return { - model: toasted.model, - commands: [ - ...(toasted.commands ?? []), - replace ? ReplaceUrl({ url: href }) : NavigateInternal({ url: href }), - ], - } - }, - RequestedToast: - ({ toast }) => - (model) => - withToasts(model, Toasts.push(model.toasts, toast)), - RequestedModal: - ({ modal }) => - (model) => - withModal(model, Modal.open(model.modal, modal, sharedOf(model))), - RequestedCommandPalette: - ({ page }) => - (model) => - withCommandPalette(model, CommandPalette.open(model.commandPalette, page, sharedOf(model))), - RequestedSignOut: () => (model) => ({ model, commands: [SignOut()] }), - RequestedTheme: - ({ preference }) => - (model) => - requestTheme(model, preference), - RequestedSoundSettings: - ({ settings }) => - (model) => - Update.combine<Model, Message, Resources>(modifyFields(model, { soundSettings: () => settings }), [ - (m) => ({ model: m, commands: [SaveSoundSettings({ settings })] }), - informPage, - ]), - RequestedCurrentUserRefresh: - ({ toast }) => - (model) => { - const toasted = toast === undefined ? { model } : withToasts(model, Toasts.push(model.toasts, toast)) - return { model: toasted.model, commands: [...(toasted.commands ?? []), FetchCurrentUser()] } - }, - RequestedUpdateCheck: () => (model) => withUpdates(model, Updates.check(model.desktopUpdate)), - RequestedUpdateInstall: () => (model) => withUpdates(model, Updates.install(model.desktopUpdate)), + RequestedNavigation: ({ route, replace, toast }) => + Update.combine([showToastIf(toast), navigateTo(route, { replace })]), + RequestedToast: ({ toast }) => showToast(toast), + RequestedModal: ({ modal }) => openModal(modal), + RequestedCommandPalette: ({ page }) => openCommandPalette(page), + RequestedSignOut: () => command(SignOut()), + RequestedTheme: ({ preference }) => requestedTheme(preference), + RequestedSoundSettings: ({ settings }) => + Update.combine([ + setModel((model) => modifyFields(model, { soundSettings: () => settings })), + command(SaveSoundSettings({ settings })), + informPage, + ]), + RequestedCurrentUserRefresh: ({ toast }) => Update.combine([showToastIf(toast), refreshCurrentUser]), + RequestedUpdateCheck: () => stepUpdates(Updates.check), + RequestedUpdateInstall: () => stepUpdates(Updates.install), // The shell owns its sheet: the root sends it the same Message as its header button. - RequestedMobileSidebar: () => (model) => { - const result = Shell.update(model.shell, ShellMessage.ToggledSidebar({ isOpen: true }), shellContextOf(model)) - return { - model: modifyFields(model, { shell: () => result.model }), - commands: Command.mapMessages(result.commands, (child) => Message.GotShellMessage({ message: child })), - } - }, + RequestedMobileSidebar: () => (model) => + foldShell(model, (shell) => + Shell.update(shell, ShellMessage.ToggledSidebar({ isOpen: true }), shellContextOf(model)), + ), }) -const applyPage = - (transition: PageTransition): Step => - (model) => - withCommands( - { - model: modifyFields(model, { page: () => transition.slot }), - commands: Command.mapMessages(transition.commands, toPageMessage), - }, - transition.outMessage, - ) +// SESSION + +/** `beforeLoad` redirects and the signed-out gate, re-checked whenever the route or session changes. */ +const redirect: Step = (model) => + Option.match( + Option.orElse(routeRedirect(model.route, { isProd: IS_PROD }), () => + authRedirect(model.route, authOf(model.session), model.currentUrl), + ), + { onNone: () => ({ model }), onSome: (route) => navigateTo(route, { replace: true })(model) }, + ) + +/** Everything that reads the session: the page, the shell and the signed-out gate. */ +const sessionChanged: Step = Update.combine([informPage, informShell, redirect]) + +/** `useAtomRefresh(userAtom)`: `user.me` again, keeping the user on screen while it runs. */ +const refreshCurrentUser: Step = (model) => + model.session._tag !== "SignedIn" + ? { model } + : Option.match(AsyncData.revalidateOrLoad(model.session.user), { + onNone: () => ({ model }), + onSome: (user) => ({ + model: modifyFields(model, { session: () => Session.SignedIn({ user, retries: 0 }) }), + commands: [FetchCurrentUser()], + }), + }) + +const signedInSession = Session.SignedIn({ user: AsyncData.Loading(), retries: 0 }) + +const changedAuth = (auth: "SignedIn" | "SignedOut"): Step => (model) => { + if (auth === "SignedOut") + return model.session._tag === "SignedOut" + ? { model } + : sessionChanged(modifyFields(model, { session: () => Session.SignedOut() })) + // A session check that confirms the session changes nothing; a new one loads its user. + if (model.session._tag === "SignedIn") return { model } + return Update.combine(modifyFields(model, { session: () => signedInSession }), [ + sessionChanged, + command(FetchCurrentUser()), + ]) +} -/** The shell's menus and sidebar follow the route, organization, user and role. */ -const informShell: Step = (model) => { - const result = Shell.informContext(model.shell, shellContextOf(model)) +/** The API did not answer the session check. A signed-in session stays: only the boot check can stall. */ +const failedCheckSession = (reason: CommonReason): Step => (model) => { + if (model.session._tag === "SignedIn" || model.session._tag === "SignedOut") return { model } + const retries = model.session._tag === "Unreachable" ? model.session.retries : 0 + const isAutoRetried = retries < MAX_AUTO_RETRIES return { - model: modifyFields(model, { shell: () => result.model }), - commands: Command.mapMessages(result.commands, (message) => Message.GotShellMessage({ message })), + model: modifyFields(model, { session: () => Session.Unreachable({ reason, retries: retries + 1 }) }), + commands: isAutoRetried ? [RetryCheckSession()] : [], } } -const informPage: Step = (model) => applyPage(informShared(model.page, sharedOf(model)))(model) +/** `user.me` failed. A transient failure is retried a few times; after that, or an ended session, it shows. */ +const failedFetchCurrentUser = (reason: CommonReason): Step => (model) => { + if (model.session._tag !== "SignedIn") return { model } + const { user, retries } = model.session + const hasUser = AsyncData.hasData(user) + if (!hasUser && isRetryableReason(reason) && retries < MAX_AUTO_RETRIES) + return { + model: modifyFields(model, { session: () => Session.SignedIn({ user, retries: retries + 1 }) }), + commands: [RetryFetchCurrentUser()], + } + const failed = Session.SignedIn({ + user: Option.match(AsyncData.getData(user), { + onNone: () => AsyncData.Failure({ error: reason }), + onSome: (data) => AsyncData.Stale({ error: reason, data }), + }), + retries, + }) + // An ended session re-reads it: if it is gone, ChangedAuth sends the browser to sign-in. + return Update.combine(modifyFields(model, { session: () => failed }), [ + sessionChanged, + reason === "SignedOut" ? command(CheckSession()) : unchanged, + ]) +} -/** `beforeLoad` redirects and the signed-out gate, re-checked whenever the route or auth changes. */ -const redirect: Step = (model) => { - const target = Option.orElse(routeRedirect(model.route, { isProd: import.meta.env.PROD }), () => - authRedirect(model.route, model.auth, model.currentUrl), +// ORGANIZATION + +const updatedOrganization = (orgSlug: string, organization: Organization | null): Step => (model) => { + const current = model.org + if (current._tag === "None" || current.slug !== orgSlug) return { model } + const next: OrgState = + organization === null + ? OrgState.NotFound({ slug: orgSlug }) + : OrgState.Loaded({ + slug: orgSlug, + organization, + // The role belongs to one organization; another one's must not show until its own arrives. + member: + current._tag === "Loaded" && current.organization.id === organization.id + ? current.member + : MemberData.Loading(), + }) + return Update.combine(modifyFields(model, { org: () => next }), [informPage, informShell]) +} + +/** The user's row in the loaded organization; a row for another organization is stale and ignored. */ +const updatedMember = (organizationId: OrganizationId, member: Member | null): Step => (model) => { + const current = model.org + if (current._tag !== "Loaded" || current.organization.id !== organizationId) return { model } + return Update.combine( + modifyFields(model, { + org: () => OrgState.Loaded({ ...current, member: MemberData.Success({ data: member }) }), + }), + [informPage, informShell], ) - return { - model, - commands: Option.match(target, { onNone: () => [], onSome: (url) => [ReplaceUrl({ url })] }), - } } +// URL + const withUrl = (model: Model, url: Url): Model => { const route = urlToAppRoute(url) return modifyFields(model, { route: () => route, - pathname: () => url.pathname, // Foldkit's `Url.search` holds the query without its `?`. currentUrl: () => Option.match(url.search, { onNone: () => url.pathname, onSome: (search) => `${url.pathname}?${search}` }), + org: (org) => orgStateFor(org, route), }) } @@ -361,54 +442,54 @@ const isSameCustomization = (first: ThemeCustomization, second: ThemeCustomizati * The legacy theme atoms' effects after a change: re-apply when the resolved mode or the * customization changed, persist a user's choice, and tell the page when `Shared.theme` moved. */ -const transitionTheme = (previous: Model, next: Model, shouldPersist: boolean): Return => { - const resolved = resolvedThemeOf(next) - const { mode, customization } = next.themePreference - const isApplied = - resolved !== resolvedThemeOf(previous) || - !isSameCustomization(customization, previous.themePreference.customization) - const commands = [ - ...(isApplied ? [ApplyTheme({ resolved, customization })] : []), - ...(shouldPersist ? [SaveThemePreference({ preference: next.themePreference })] : []), - ] - return isApplied || mode !== previous.themePreference.mode - ? Update.combine<Model, Message, Resources>(next, [(m) => ({ model: m, commands }), informPage]) - : { model: next, commands } -} +const transitionTheme = + (next: (model: Model) => Model, shouldPersist: boolean): Step => + (previous) => { + const model = next(previous) + const resolved = resolvedThemeOf(model) + const { mode, customization } = model.themePreference + const isApplied = + resolved !== resolvedThemeOf(previous) || + !isSameCustomization(customization, previous.themePreference.customization) + const commands = [ + ...(isApplied ? [ApplyTheme({ resolved, customization })] : []), + ...(shouldPersist ? [SaveThemePreference({ preference: model.themePreference })] : []), + ] + return Update.combine(model, [ + command(...commands), + isApplied || mode !== previous.themePreference.mode ? informPage : unchanged, + ]) + } /** `RequestedTheme` from a page or the palette: the user's choice, applied and persisted. */ -export const requestTheme = (model: Model, preference: ThemePreference): Return => - transitionTheme(model, modifyFields(model, { themePreference: () => preference }), true) +const requestedTheme = (preference: ThemePreference): Step => + transitionTheme((model) => modifyFields(model, { themePreference: () => preference }), true) -const appliedTheme: Step = (model) => ({ - model, - commands: [ApplyTheme({ resolved: resolvedThemeOf(model), customization: model.themePreference.customization })], -}) +export const requestTheme = (model: Model, preference: ThemePreference): Return => requestedTheme(preference)(model) -const enteredRoute: Step = (model) => applyPage(enterRoute(model.page, model.route, sharedOf(model)))(model) +const appliedTheme: Step = (model) => + command(ApplyTheme({ resolved: resolvedThemeOf(model), customization: model.themePreference.customization }))( + model, + ) // INIT export const init: Runtime.RoutingApplicationInit<Model, Message, Flags, Resources> = (flags, url) => { - const model = withUrl( + const route = urlToAppRoute(url) + const model: Model = withUrl( { - route: { _tag: "Root" }, - pathname: "", + route, currentUrl: "", - auth: "Loading", - currentUser: null, - organization: null, - orgLoad: null, - isSessionStalled: false, - isCurrentUserStalled: false, - member: null, - nowMs: 0, + session: Session.Loading(), + org: OrgState.None(), + isMobile: flags.isMobile, + nowMs: flags.sessionStartMs, themePreference: flags.themePreference, systemTheme: flags.systemTheme, soundSettings: flags.soundSettings, sessionStartMs: flags.sessionStartMs, page: null, - shell: Shell.init(), + shell: Shell.init({ isCreateChannelHintDismissed: flags.isCreateChannelHintDismissed }), modal: null, commandPalette: CommandPalette.init(), toasts: Toasts.init(), @@ -417,17 +498,14 @@ export const init: Runtime.RoutingApplicationInit<Model, Message, Flags, Resourc }, url, ) - return Update.combine<Model, Message, Resources>(model, [ - appliedTheme, - enteredRoute, - informShell, - redirect, - (m) => ({ model: m, commands: [CheckSession()] }), - ]) + return Update.combine(model, [appliedTheme, enteredRoute, informShell, redirect, command(CheckSession())]) } // UPDATE +const savingFailed = (title: string) => + showToast(errorToast(title, "It applies for now, but won't be remembered next time.")) + export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { ClickedLink: ({ request }) => @@ -435,113 +513,66 @@ export const update = (model: Model, message: Message): Return => Internal: ({ url }) => ({ model, commands: [NavigateInternal({ url: urlToString(url) })] }), External: ({ href }) => ({ model, commands: [LoadExternal({ href })] }), }), - ChangedUrl: ({ url }) => - Update.combine<Model, Message, Resources>(withUrl(model, url), [ - enteredRoute, - informShell, - redirect, - ]), + ChangedUrl: ({ url }) => Update.combine(withUrl(model, url), [enteredRoute, informShell, redirect]), CompletedNavigateInternal: () => ({ model }), CompletedReplaceUrl: () => ({ model }), CompletedLoadExternal: () => ({ model }), ChangedSystemTheme: ({ theme }) => - transitionTheme(model, modifyFields(model, { systemTheme: () => theme }), false), + transitionTheme((current) => modifyFields(current, { systemTheme: () => theme }), false)(model), + ChangedViewport: ({ isMobile }) => + Update.combine(modifyFields(model, { isMobile: () => isMobile }), [informPage, informShell]), CompletedApplyTheme: () => ({ model }), CompletedSaveThemePreference: () => ({ model }), + FailedSaveThemePreference: () => savingFailed("Couldn't save your theme")(model), CompletedSaveSoundSettings: () => ({ model }), + FailedSaveSoundSettings: () => savingFailed("Couldn't save your sound settings")(model), UpdatedRecentNotifications: ({ ids }) => ({ model, commands: ids.length === 0 ? [] : [DeliverNotifications({ ids })], }), CompletedDeliverNotifications: () => ({ model }), - ChangedAuth: ({ auth }) => { - const next = modifyFields(model, { auth: () => auth, isSessionStalled: () => false }) - // Legacy `userAtom` runs wherever `useAuth()` mounts: the join page queries it signed out too. - const isUserQueried = - (auth === "SignedIn" && model.auth !== "SignedIn") || - (auth === "SignedOut" && model.auth === "Loading" && model.route._tag === "Join") - const fetchUser = isUserQueried ? [FetchCurrentUser()] : [] - return Update.combine<Model, Message, Resources>(next, [ - informPage, - redirect, - (m) => ({ model: m, commands: fetchUser }), - ]) - }, + ChangedAuth: ({ auth }) => changedAuth(auth)(model), + FailedCheckSession: ({ reason }) => failedCheckSession(reason)(model), + PressedRetrySession: () => + model.session._tag === "Unreachable" + ? { model: modifyFields(model, { session: () => Session.Loading() }), commands: [CheckSession()] } + : { model }, RejectedSession: () => ({ model, commands: [CheckSession()] }), - FailedCheckSession: () => ({ - model: modifyFields(model, { isSessionStalled: () => true }), - commands: [RetryCheckSession()], - }), SucceededFetchCurrentUser: ({ user }) => - Update.combine<Model, Message, Resources>( - modifyFields(model, { currentUser: () => user, isCurrentUserStalled: () => false }), - [informPage, informShell], - ), - // A signed-in shell has nothing to show without its user, so it keeps asking. Unauthorized - // also re-reads the session: if it has ended, ChangedAuth sends the browser to sign-in. - FailedFetchCurrentUser: ({ isUnauthorized }) => - model.auth !== "SignedIn" || model.currentUser !== null - ? { model } - : { - model: modifyFields(model, { isCurrentUserStalled: () => true }), - commands: [...(isUnauthorized ? [CheckSession()] : []), RetryFetchCurrentUser()], - }, - CompletedSignOut: () => ({ model }), - FailedSignOut: () => withToasts(model, Toasts.push(model.toasts, errorToast("Failed to sign out", "Please try again."))), - UpdatedOrganization: ({ orgSlug, organization }) => - Update.combine<Model, Message, Resources>( - modifyFields(model, { - organization: () => organization, - orgLoad: () => ({ slug: orgSlug, status: organization === null ? "NotFound" : "Found" }), - // The role belongs to one organization; another one's must not show until its own arrives. - member: (member) => (organization?.id === model.organization?.id ? member : null), - }), - [informPage, informShell], + model.session._tag === "SignedIn" + ? sessionChanged( + modifyFields(model, { + session: () => Session.SignedIn({ user: AsyncData.Success({ data: user }), retries: 0 }), + }), + ) + : { model }, + FailedFetchCurrentUser: ({ reason }) => failedFetchCurrentUser(reason)(model), + PressedRetryCurrentUser: () => refreshCurrentUser(model), + // A full load of `/`: every collection, token and subscription starts over, signed out. + SucceededSignOut: () => ({ model, commands: [LoadExternal({ href: hrefOf(AppRoute.Root()) })] }), + FailedSignOut: ({ reason }) => + showToast(errorToastOf({ title: "Failed to sign out", description: commonMessage(reason).description }))( + model, ), + UpdatedOrganization: ({ orgSlug, organization }) => updatedOrganization(orgSlug, organization)(model), // Only a slug still loading counts: data already on screen stays when its sync hiccups. StalledOrganization: ({ orgSlug }) => - model.orgLoad?.slug === orgSlug && model.orgLoad.status !== "Stalled" - ? { model } - : { - model: modifyFields(model, { - orgLoad: () => ({ slug: orgSlug, status: "Stalled" as const }), - }), - }, + model.org._tag === "Loading" && model.org.slug === orgSlug && !model.org.isStalled + ? { model: modifyFields(model, { org: () => OrgState.Loading({ slug: orgSlug, isStalled: true }) }) } + : { model }, PressedReload: () => ({ model, commands: [Reload()] }), CompletedReload: () => ({ model }), - UpdatedMember: ({ member }) => - Update.combine<Model, Message, Resources>(modifyFields(model, { member: () => member }), [ - informPage, - informShell, - ]), - TickedPresenceClock: ({ nowMs }) => ({ model: modifyFields(model, { nowMs: () => nowMs }) }), - ClickedLayoutTab: ({ href }) => ({ model, commands: [NavigateInternal({ url: href })] }), - GotPageMessage: ({ message }) => applyPage(updatePage(model.page, message, sharedOf(model)))(model), - GotShellMessage: ({ message }) => { - const result = Shell.update(model.shell, message, shellContextOf(model)) - const shelled = withCommands( - { - model: modifyFields(model, { shell: () => result.model }), - commands: Command.mapMessages(result.commands, (child) => - Message.GotShellMessage({ message: child }), - ), - }, - Option.fromNullishOr(result.outMessage), - ) - // `Shared.isMobile` comes from the shell's viewport query. - return result.model.isMobile === model.shell.isMobile - ? shelled - : Update.combine<Model, Message, Resources>(shelled.model, [ - (m) => ({ model: m, commands: shelled.commands }), - informPage, - ]) - }, - GotModalMessage: ({ message }) => withModal(model, Modal.update(model.modal, message, sharedOf(model))), + UpdatedMember: ({ organizationId, member }) => updatedMember(organizationId, member)(model), + TickedClock: ({ nowMs }) => ({ model: modifyFields(model, { nowMs: () => nowMs }) }), + ClickedLayoutTab: ({ route }) => navigateTo(route)(model), + GotPageMessage: ({ message }) => foldPage(model, (page) => updatePage(page, message, sharedOf(model))), + GotShellMessage: ({ message }) => foldShell(model, (shell) => Shell.update(shell, message, shellContextOf(model))), + GotModalMessage: ({ message }) => foldModal(model, (modal) => Modal.update(modal, message, sharedOf(model))), GotCommandPaletteMessage: ({ message }) => - withCommandPalette(model, CommandPalette.update(model.commandPalette, message, sharedOf(model))), - PressedHotkey: ({ actionId }) => pressedHotkey(model, actionId), - GotToastsMessage: ({ message }) => followToasts(model, Toasts.update(model.toasts, message)), - GotUpdatesMessage: ({ message }) => withUpdates(model, Updates.update(model.desktopUpdate, message)), - PressedDesktopMenuItem: ({ item }) => pressedDesktopMenuItem(model, item), - GotPlatformMessage: ({ message }) => withPlatform(model, Platform.update(model.platform, message)), + foldCommandPalette(model, (palette) => CommandPalette.update(palette, message, sharedOf(model))), + PressedHotkey: ({ actionId }) => pressedHotkey(actionId)(model), + GotToastsMessage: ({ message }) => foldToastsWithAction(model, message), + GotUpdatesMessage: ({ message }) => stepUpdates((updates) => Updates.update(updates, message))(model), + PressedDesktopMenuItem: ({ item }) => pressedDesktopMenuItem(item)(model), + GotPlatformMessage: ({ message }) => foldPlatform(model, (platform) => Platform.update(platform, message)), }) diff --git a/apps/web-foldkit/src/notification-sound.test.ts b/apps/web-foldkit/src/notification-sound.test.ts index 234a91b06..50c6ce811 100644 --- a/apps/web-foldkit/src/notification-sound.test.ts +++ b/apps/web-foldkit/src/notification-sound.test.ts @@ -3,7 +3,7 @@ import { UserId } from "@hazel/schema" import { Effect, Fiber, Schema, Stream } from "effect" import { beforeEach, describe, expect, test, vi } from "vitest" import { notificationSoundManager } from "~/lib/notification-sound-manager" -import { wireNotificationSinks } from "./app/notification-sinks" +import { NotificationSinks, wireNotificationSinks } from "./app/notification-sinks" import { DEFAULT_SOUND_SETTINGS, isInQuietHours, @@ -72,7 +72,7 @@ describe("notification sinks", () => { const fiber = Effect.runFork( Stream.runDrain( wireNotificationSinks({ userId: ada, settings, currentChannelId: null, sessionStartMs: 0 }), - ), + ).pipe(Effect.provide(NotificationSinks.layer)), ) await Effect.runPromise(Effect.yieldNow) const result = await notificationSoundManager.testSound() diff --git a/apps/web-foldkit/src/overlay/modal/create-dm.ts b/apps/web-foldkit/src/overlay/modal/create-dm.ts index a6303591b..2f43cd5fb 100644 --- a/apps/web-foldkit/src/overlay/modal/create-dm.ts +++ b/apps/web-foldkit/src/overlay/modal/create-dm.ts @@ -6,7 +6,7 @@ import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { HazelRpc } from "../../rpc" import type { Shared } from "../../page/contract" -import { AppRoute, orgHrefOf } from "../../route" +import { AppRoute, orgRouteOf } from "../../route" import { closed, completed, ModalOutMessage } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput } from "./contract" @@ -50,8 +50,8 @@ const StartConversation = Command.define("StartConversation", { type Return = ModalReturn<Model, Message> -const chatHref = (shared: Shared, channelId: ChannelId) => - orgHrefOf(shared.orgSlug, (orgSlug) => AppRoute.ChatChannel({ orgSlug, channelId })) ?? undefined +const chatRoute = (shared: Shared, channelId: ChannelId) => + orgRouteOf(shared.orgSlug, (orgSlug) => AppRoute.ChatChannel({ orgSlug, channelId })) const submitted = (model: Model, shared: Shared): Return => { const ids = model.selectedUserIds @@ -104,10 +104,10 @@ const update = (model: Model, message: Message, shared: Shared): Return => ClickedCancel: () => ({ model, outMessage: closed }), ClickedStartConversation: () => submitted(model, shared), UpdatedOrganizationUsers: ({ users }) => ({ model: modifyFields(model, { organizationUsers: () => users }) }), - FoundExistingDm: ({ channelId }) => ({ model, outMessage: completed({ href: chatHref(shared, channelId) }) }), + FoundExistingDm: ({ channelId }) => ({ model, outMessage: completed({ route: chatRoute(shared, channelId) }) }), SucceededCreateDm: ({ channelId }) => ({ model, - outMessage: completed({ href: chatHref(shared, channelId), toast: successToast(successMessage(model)) }), + outMessage: completed({ route: chatRoute(shared, channelId), toast: successToast(successMessage(model)) }), }), FailedCreateDm: ({ toast }) => ({ model: modifyFields(model, { isSubmitting: () => false }), diff --git a/apps/web-foldkit/src/overlay/modal/create-organization.ts b/apps/web-foldkit/src/overlay/modal/create-organization.ts index b3fdc4a80..d3bc9c2e2 100644 --- a/apps/web-foldkit/src/overlay/modal/create-organization.ts +++ b/apps/web-foldkit/src/overlay/modal/create-organization.ts @@ -4,7 +4,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { IconServers } from "../../icons" -import { organizationHref } from "../../route" +import { AppRoute, organizationRoute } from "../../route" import { HazelRpc } from "../../rpc" import { button } from "../../ui/button" import { dialogBody, dialogFooter, dialogHeader } from "../../ui/dialog" @@ -38,7 +38,7 @@ const Message = defineMessageUnion({ ChangedSlug: { value: Schema.String }, ClickedCancel: {}, SubmittedForm: {}, - SucceededCreateOrganization: { href: Schema.String }, + SucceededCreateOrganization: { route: AppRoute }, FailedCreateOrganization: { toast: ToastRequest }, }) type Message = typeof Message.Type @@ -56,7 +56,7 @@ const CreateOrganization = Command.define("CreateOrganization", { settings: null, isPublic: false, }) - return Message.SucceededCreateOrganization({ href: organizationHref(result.data) }) + return Message.SucceededCreateOrganization({ route: organizationRoute(result.data) }) }).pipe( Effect.catchCause((cause) => Effect.succeed( @@ -110,9 +110,9 @@ const update = (model: Model, message: Message): Return => }), ClickedCancel: () => ({ model, outMessage: closed }), SubmittedForm: () => submitted(model), - SucceededCreateOrganization: ({ href }) => ({ + SucceededCreateOrganization: ({ route }) => ({ model, - outMessage: completed({ href, toast: successToast("Server created successfully") }), + outMessage: completed({ route, toast: successToast("Server created successfully") }), }), FailedCreateOrganization: ({ toast }) => ({ model: modifyFields(model, { isSubmitting: () => false }), diff --git a/apps/web-foldkit/src/overlay/modal/delete-channel.ts b/apps/web-foldkit/src/overlay/modal/delete-channel.ts index 6a9139db0..1fbb800b7 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-channel.ts @@ -80,7 +80,7 @@ const update = (model: Model, message: Message, shared: Shared): Return => model, outMessage: completed({ ...(isOnDeletedChannel && shared.orgSlug !== null - ? { href: hrefOf(AppRoute.ChatIndex({ orgSlug: shared.orgSlug })) } + ? { route: AppRoute.ChatIndex({ orgSlug: shared.orgSlug }) } : {}), toast: successToast("Channel deleted successfully"), }), diff --git a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts index 7aa239e74..e2e316ed2 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts @@ -4,6 +4,7 @@ import { Command, Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { IconWarning } from "../../icons" +import { AppRoute } from "../../route" import { HazelRpc } from "../../rpc" import { button } from "../../ui/button" import { dialogBody, dialogFooter, dialogHeader } from "../../ui/dialog" @@ -89,7 +90,7 @@ const update = (model: Model, message: Message): Return => // The settings page's `onDeleted` navigated to the root. SucceededDeleteWorkspace: () => ({ model, - outMessage: completed({ href: "/", toast: successToast("Workspace deleted successfully") }), + outMessage: completed({ route: AppRoute.Root(), toast: successToast("Workspace deleted successfully") }), }), FailedDeleteWorkspace: ({ toast }) => ({ model: modifyFields(model, { isDeleting: () => false }), diff --git a/apps/web-foldkit/src/overlay/modal/new-channel.ts b/apps/web-foldkit/src/overlay/modal/new-channel.ts index 1c821a031..fb3fcd248 100644 --- a/apps/web-foldkit/src/overlay/modal/new-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/new-channel.ts @@ -6,7 +6,7 @@ import { modifyFields } from "foldkit/struct" import { createChannelAction } from "~/db/actions" import { IconHashtag } from "../../icons" import type { Shared } from "../../page/contract" -import { AppRoute, orgHrefOf } from "../../route" +import { AppRoute, orgRouteOf } from "../../route" import { button } from "../../ui/button" import { checkbox } from "../../ui/checkbox" import { dialogBody, dialogFooter, dialogHeader } from "../../ui/dialog" @@ -111,9 +111,7 @@ const update = (model: Model, message: Message, shared: Shared): Return => SucceededCreateChannel: ({ channelId }) => ({ model, outMessage: completed({ - href: - orgHrefOf(shared.orgSlug, (orgSlug) => AppRoute.ChatChannel({ orgSlug, channelId })) ?? - undefined, + route: orgRouteOf(shared.orgSlug, (orgSlug) => AppRoute.ChatChannel({ orgSlug, channelId })), toast: successToast("Channel created successfully"), }), }), diff --git a/apps/web-foldkit/src/overlay/out-message.ts b/apps/web-foldkit/src/overlay/out-message.ts index d8358f979..7ca5806d0 100644 --- a/apps/web-foldkit/src/overlay/out-message.ts +++ b/apps/web-foldkit/src/overlay/out-message.ts @@ -1,19 +1,20 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { AppRoute } from "../route" import { ToastRequest } from "./toasts" /** - * What a modal reports to the root. `Completed` closes the modal, then follows `href` and shows + * What a modal reports to the root. `Completed` closes the modal, then goes to `route` and shows * `toast` (legacy `exitToast(...).onSuccess(navigate; close)`); `RequestedToast` keeps it open. */ export const ModalOutMessage = defineMessageUnion({ Closed: {}, - Completed: { href: Schema.NullOr(Schema.String), toast: Schema.NullOr(ToastRequest) }, + Completed: { route: Schema.NullOr(AppRoute), toast: Schema.NullOr(ToastRequest) }, RequestedToast: { toast: ToastRequest }, }) export type ModalOutMessage = typeof ModalOutMessage.Type export const closed = ModalOutMessage.Closed() -export const completed = (options: { readonly href?: string; readonly toast?: ToastRequest }) => - ModalOutMessage.Completed({ href: options.href ?? null, toast: options.toast ?? null }) +export const completed = (options: { readonly route?: AppRoute; readonly toast?: ToastRequest }) => + ModalOutMessage.Completed({ route: options.route ?? null, toast: options.toast ?? null }) diff --git a/apps/web-foldkit/src/overlay/toaster.test.ts b/apps/web-foldkit/src/overlay/toaster.test.ts index aa9d50846..c61906b4b 100644 --- a/apps/web-foldkit/src/overlay/toaster.test.ts +++ b/apps/web-foldkit/src/overlay/toaster.test.ts @@ -48,6 +48,8 @@ describe("root toaster", () => { systemTheme: "light", soundSettings: DEFAULT_SOUND_SETTINGS, sessionStartMs: 0, + isMobile: false, + isCreateChannelHintDismissed: false, }, url, ), diff --git a/apps/web-foldkit/src/page/auth/layout.ts b/apps/web-foldkit/src/page/auth/layout.ts index 3a54e7e61..a1859e672 100644 --- a/apps/web-foldkit/src/page/auth/layout.ts +++ b/apps/web-foldkit/src/page/auth/layout.ts @@ -62,7 +62,7 @@ export const signInLink = <M>(h: HtmlBuilder<M>, label: string): Html => h.a( [ h.Class("text-fg text-sm underline underline-offset-2"), - h.Href(hrefOf(AppRoute.SignIn({ splat: "", redirectUrl: Option.none() }))), + h.Href(hrefOf(AppRoute.SignIn({ redirectUrl: Option.none() }))), ], [label], ) diff --git a/apps/web-foldkit/src/page/auth/scene.test.ts b/apps/web-foldkit/src/page/auth/scene.test.ts index 6ff493a5f..ed15cfead 100644 --- a/apps/web-foldkit/src/page/auth/scene.test.ts +++ b/apps/web-foldkit/src/page/auth/scene.test.ts @@ -20,7 +20,7 @@ const github = Scene.role("button", { name: "Continue with GitHub" }) const passkey = Scene.role("button", { name: "Sign in with a passkey" }) const signIn = (redirectUrl: Option.Option<string>) => - initSignIn(AppRoute.SignIn.make({ splat: "", redirectUrl })).model + initSignIn(AppRoute.SignIn.make({ redirectUrl })).model describe("sign in", () => { test("a provider starts OAuth with the route's redirect, and disables the other choices", () => { @@ -65,7 +65,7 @@ describe("sign in", () => { test("/sign-up offers the same choices and links back to sign-in", () => { Scene.scene( pageScene(updateSignIn, signInView, signedOut), - Scene.given(initSignIn(AppRoute.SignUp.make({ splat: "", redirectUrl: Option.none() })).model), + Scene.given(initSignIn(AppRoute.SignUp.make({ redirectUrl: Option.none() })).model), Scene.expect(Scene.role("heading", { name: "Create your Hazel account" })).toExist(), Scene.expect(Scene.role("link", { name: "Sign in" })).toHaveAttr("href", "/sign-in"), ) diff --git a/apps/web-foldkit/src/page/auth/sign-in/view.ts b/apps/web-foldkit/src/page/auth/sign-in/view.ts index db1354dc9..8adeb1423 100644 --- a/apps/web-foldkit/src/page/auth/sign-in/view.ts +++ b/apps/web-foldkit/src/page/auth/sign-in/view.ts @@ -89,7 +89,7 @@ const switchLink = (h: HtmlBuilder<Message>, model: Model): Html => { [ h.Class("text-fg underline underline-offset-2"), h.Href( - hrefOf(target({ splat: "", redirectUrl: Option.fromNullishOr(model.redirectUrl) })), + hrefOf(target({ redirectUrl: Option.fromNullishOr(model.redirectUrl) })), ), ], [isSignIn ? "Create an account" : "Sign in"], diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts index c31d24f5f..9ee6ead31 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts @@ -1,3 +1,4 @@ +import { routeOfPath } from "../../../route" import "../document-stub" import { ChannelId, ChannelWebhookId } from "@hazel/schema" import { Schema } from "effect" @@ -509,7 +510,7 @@ describe("copy and GitHub", () => { message(Message.ClickedConnectGitHub()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/github", + route: routeOfPath("/hazel/settings/integrations/github"), replace: false, }), ), diff --git a/apps/web-foldkit/src/page/channel-settings/redirect/index.ts b/apps/web-foldkit/src/page/channel-settings/redirect/index.ts index 2a8098df1..132dc18b7 100644 --- a/apps/web-foldkit/src/page/channel-settings/redirect/index.ts +++ b/apps/web-foldkit/src/page/channel-settings/redirect/index.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" -import { AppRoute, hrefOf } from "../../../route" +import { AppRoute } from "../../../route" import { definePage, type PageViewInputs } from "../../contract" import { PageOutMessage } from "../../out-message" @@ -18,9 +18,7 @@ export const page = definePage( init: (route) => ({ model: {}, outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf( - AppRoute.ChannelSettingsOverview({ orgSlug: route.orgSlug, channelId: route.channelId }), - ), + route: AppRoute.ChannelSettingsOverview({ orgSlug: route.orgSlug, channelId: route.channelId }), replace: true, }), }), diff --git a/apps/web-foldkit/src/page/chat/channel/index.ts b/apps/web-foldkit/src/page/chat/channel/index.ts index 6c3e9f76e..515f1566f 100644 --- a/apps/web-foldkit/src/page/chat/channel/index.ts +++ b/apps/web-foldkit/src/page/chat/channel/index.ts @@ -10,7 +10,7 @@ import { setCurrentUserId, setTab, switchChannel, - tabPath, + tabRoute, update, } from "./page" import { clearNotificationsOnMount } from "./clear-notifications" @@ -49,7 +49,7 @@ export const page = definePage( ? { model, outMessage: PageOutMessage.RequestedNavigation({ - href: tabPath(model.orgSlug, model.channelId, message.tab), + route: tabRoute(model.orgSlug, model.channelId, message.tab), replace: false, }), } diff --git a/apps/web-foldkit/src/page/chat/channel/page.ts b/apps/web-foldkit/src/page/chat/channel/page.ts index 1835273ba..dc7011a8b 100644 --- a/apps/web-foldkit/src/page/chat/channel/page.ts +++ b/apps/web-foldkit/src/page/chat/channel/page.ts @@ -7,7 +7,7 @@ import { modifyFields } from "foldkit/struct" import * as Composer from "../../../composer/composer" import * as Draft from "../../../composer/draft" import * as MessageList from "../../../mount/message-list" -import { AppRoute, hrefOf } from "../../../route" +import { AppRoute } from "../../../route" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import { replyIdsOf, threadIdsOf, toDeriveContext, toDisplayRows, unfurlRequestsOf } from "../derive" @@ -141,8 +141,8 @@ const tabRoutes = { media: AppRoute.ChatFilesMedia, } as const -export const tabPath = (orgSlug: string, channelId: ChannelId, tab: ChatTab) => - hrefOf(tabRoutes[tab]({ orgSlug, channelId })) +export const tabRoute = (orgSlug: string, channelId: ChannelId, tab: ChatTab): AppRoute => + tabRoutes[tab]({ orgSlug, channelId }) // UPDATE diff --git a/apps/web-foldkit/src/page/home/scene.test.ts b/apps/web-foldkit/src/page/home/scene.test.ts index 9ad023b34..9ae108e0e 100644 --- a/apps/web-foldkit/src/page/home/scene.test.ts +++ b/apps/web-foldkit/src/page/home/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../route" import { UserId } from "@hazel/schema" import { Schema } from "effect" import * as Scene from "foldkit/scene" @@ -90,7 +91,7 @@ describe("member directory", () => { Scene.Subscription.emit(Message.UpdatedDmChannels({ rows: dmWithGrace })), Scene.click(messageButton), Scene.Command.expectNone(), - Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ href: `/hazel/chat/${channelId}`, replace: false })), + Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(`/hazel/chat/${channelId}`), replace: false })), ) }) @@ -103,7 +104,7 @@ describe("member directory", () => { Scene.click(messageButton), Scene.expectNoOutMessage(), Scene.Subscription.emit(Message.UpdatedDmChannels({ rows: dmWithGrace })), - Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ href: `/hazel/chat/${channelId}`, replace: false })), + Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(`/hazel/chat/${channelId}`), replace: false })), ) }) diff --git a/apps/web-foldkit/src/page/home/story.test.ts b/apps/web-foldkit/src/page/home/story.test.ts index 608638dec..8aed0b804 100644 --- a/apps/web-foldkit/src/page/home/story.test.ts +++ b/apps/web-foldkit/src/page/home/story.test.ts @@ -1,3 +1,4 @@ +import { routeOfPath } from "../../route" import { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Option, Schema } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" @@ -122,7 +123,7 @@ describe("org home directory", () => { message(Message.PressedMessageMember({ userId: grace, name: "Grace Hopper" })), Command.expectNone(), expectOutMessage( - PageOutMessage.RequestedNavigation({ href: `/hazel/chat/${channelId}`, replace: false }), + PageOutMessage.RequestedNavigation({ route: routeOfPath(`/hazel/chat/${channelId}`), replace: false }), ), model((current) => expect(current.dmRequest).toEqual(DmRequest.Idle())), ) @@ -147,7 +148,7 @@ describe("org home directory", () => { Command.resolve(CreateDm, Message.SucceededCreateDm({ channelId, name: "Grace Hopper" })), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: `/hazel/chat/${channelId}`, + route: routeOfPath(`/hazel/chat/${channelId}`), replace: false, toast: { intent: "success", @@ -204,7 +205,7 @@ describe("org home failures and guards", () => { expectNoOutMessage(), model((current) => expect(current.dmRequest).toEqual(DmRequest.AwaitingChannels({ userId: grace }))), message(Message.UpdatedDmChannels({ rows: dmWithGrace })), - expectOutMessage(PageOutMessage.RequestedNavigation({ href: `/hazel/chat/${channelId}`, replace: false })), + expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(`/hazel/chat/${channelId}`), replace: false })), model((current) => expect(current.dmRequest).toEqual(DmRequest.Idle())), ) }) diff --git a/apps/web-foldkit/src/page/join/story.test.ts b/apps/web-foldkit/src/page/join/story.test.ts index e19f8ebc3..4a788a5ab 100644 --- a/apps/web-foldkit/src/page/join/story.test.ts +++ b/apps/web-foldkit/src/page/join/story.test.ts @@ -1,7 +1,7 @@ import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { errorToast } from "../../data/actions" -import { AppRoute } from "../../route" +import { AppRoute, routeOfPath } from "../../route" import { PageOutMessage } from "../out-message" import { FetchPublicOrganization, JoinWorkspace, RedirectToSignIn } from "./command" import { Message } from "./message" @@ -56,7 +56,7 @@ describe("join workspace", () => { Command.resolve(JoinWorkspace, Message.SucceededJoinWorkspace()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel", + route: routeOfPath("/hazel"), replace: false, toast: { intent: "success", title: "Successfully joined workspace!", description: null }, }), diff --git a/apps/web-foldkit/src/page/join/update.ts b/apps/web-foldkit/src/page/join/update.ts index 82df0e400..bfb39b0fe 100644 --- a/apps/web-foldkit/src/page/join/update.ts +++ b/apps/web-foldkit/src/page/join/update.ts @@ -34,7 +34,7 @@ export const update = (model: Model, message: Message): Return => SucceededJoinWorkspace: () => ({ model: modifyFields(model, { isJoining: () => false }), outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf(AppRoute.OrgHome({ orgSlug: model.slug })), + route: AppRoute.OrgHome({ orgSlug: model.slug }), replace: false, toast: successToast("Successfully joined workspace!"), }), diff --git a/apps/web-foldkit/src/page/my-settings/desktop/view.ts b/apps/web-foldkit/src/page/my-settings/desktop/view.ts index 47d1d7c17..b04542302 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/view.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/view.ts @@ -2,7 +2,7 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { IconCheck, IconCube, IconDownload } from "../../../icons" import { isDesktop } from "../../../platform/desktop" -import type * as Updates from "../../../platform/updates" +import * as Updates from "../../../platform/updates" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" import { progressBar, progressBarTrack } from "../../../ui/progress-bar" @@ -116,7 +116,7 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { ], ) case "CheckFailed": - return retry(h, state.message, "Retry") + return retry(h, Updates.CHECK_FAILURE_TEXT, "Retry") case "Downloading": { const percent = state.totalBytes === null ? 0 : Math.round((state.downloadedBytes / state.totalBytes) * 100) @@ -148,7 +148,7 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { case "Restarting": return spinner(h, "Restarting...") case "InstallFailed": - return retry(h, state.message, "Retry") + return retry(h, Updates.installFailureText(state.reason), "Retry") } } diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts index 37d138b32..3a1e65d3b 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import { Option } from "effect" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../out-message" @@ -19,7 +20,7 @@ describe("linked accounts callback", () => { const page = init(routeWith({ status: "success", provider: "discord" })) expect(page.outMessage).toEqual( PageOutMessage.RequestedNavigation({ - href: "/hazel/my-settings/linked-accounts", + route: routeOfPath("/hazel/my-settings/linked-accounts"), replace: true, toast: { intent: "success", title: "Discord account linked", description: null }, }), @@ -31,7 +32,7 @@ describe("linked accounts callback", () => { const page = init(routeWith({ status: "error", provider: "discord", errorCode: "db_error" })) expect(page.outMessage).toEqual( PageOutMessage.RequestedNavigation({ - href: "/hazel/my-settings/linked-accounts", + route: routeOfPath("/hazel/my-settings/linked-accounts"), replace: true, toast: { intent: "error", title: "Failed to link Discord account", description: "db_error" }, }), diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts index ab7472d1a..f87585eaf 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import { Option } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" @@ -119,7 +120,7 @@ describe("link callback", () => { expect(callback.commands).toBeUndefined() expect(callback.outMessage).toEqual( PageOutMessage.RequestedNavigation({ - href: "/hazel/my-settings/linked-accounts", + route: routeOfPath("/hazel/my-settings/linked-accounts"), replace: true, toast: { intent: "success", title: "Discord account linked", description: null }, }), diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts index 469300cb1..945531674 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts @@ -9,7 +9,7 @@ import { embedInteraction } from "../shared" import { DisconnectDiscord, StartDiscordLink } from "./command" import { Message } from "./message" import type { Model } from "./model" -import { linkedAccountsHref, type RouteOf } from "../../../route" +import { linkedAccountsRoute, type RouteOf } from "../../../route" type Return = PageReturn<Model, Message> @@ -28,7 +28,7 @@ const linkResult = (model: Model, route: Route): Return => { return { model, outMessage: PageOutMessage.RequestedNavigation({ - href: linkedAccountsHref(model.orgSlug), + route: linkedAccountsRoute(model.orgSlug), replace: true, toast: status === "success" diff --git a/apps/web-foldkit/src/page/onboarding/story.test.ts b/apps/web-foldkit/src/page/onboarding/story.test.ts index c2eceb8d3..e23110605 100644 --- a/apps/web-foldkit/src/page/onboarding/story.test.ts +++ b/apps/web-foldkit/src/page/onboarding/story.test.ts @@ -10,7 +10,7 @@ import { onboardingRoute, withForm, } from "../../test/pages-entry-fixtures" -import { onboardingHref } from "../../route" +import { onboardingHref, routeOfPath } from "../../route" import * as ChoiceBox from "../../ui/choice-box" import { PageOutMessage } from "../out-message" import { @@ -40,7 +40,7 @@ describe("start-up", () => { test("an onboarded user is sent home once, and later shared changes do not redirect again", () => { const onboarded = makeShared() const started = init(onboardingRoute(null), onboarded) - expect(started.outMessage).toEqual(PageOutMessage.RequestedNavigation({ href: "/", replace: false })) + expect(started.outMessage).toEqual(PageOutMessage.RequestedNavigation({ route: routeOfPath("/"), replace: false })) expect(started.commands?.map((command) => command.name)).toEqual([ReadBrowserTimezone.name]) expect(sharedChanged(started.model, onboarded).outMessage).toBeUndefined() }) diff --git a/apps/web-foldkit/src/page/onboarding/update.ts b/apps/web-foldkit/src/page/onboarding/update.ts index 503e5fcea..44d94078f 100644 --- a/apps/web-foldkit/src/page/onboarding/update.ts +++ b/apps/web-foldkit/src/page/onboarding/update.ts @@ -30,7 +30,7 @@ const redirectIfOnboarded = (model: Model, shared: Shared): Return => shared.currentUser?.isOnboarded && !model.hasRedirected ? { model: modifyFields(model, { hasRedirected: () => true }), - outMessage: PageOutMessage.RequestedNavigation({ href: "/", replace: false }), + outMessage: PageOutMessage.RequestedNavigation({ route: AppRoute.Root(), replace: false }), } : { model } diff --git a/apps/web-foldkit/src/page/out-message.ts b/apps/web-foldkit/src/page/out-message.ts index 57f369180..cb5a421b3 100644 --- a/apps/web-foldkit/src/page/out-message.ts +++ b/apps/web-foldkit/src/page/out-message.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { Page as CommandPalettePage } from "../overlay/command-palette/model" import { ModalRequest } from "../overlay/modal/requests" -import { type AppRoute, hrefOf } from "../route" +import { AppRoute } from "../route" import { ToastRequest } from "../overlay/toasts" import { SoundSettings } from "../notification-sound" import { ThemePreference } from "../theme" @@ -10,7 +10,7 @@ import { ThemePreference } from "../theme" /** Facts a page (or the shell) reports to the root, which owns navigation and the overlays. */ export const PageOutMessage = defineMessageUnion({ /** `toast` shows with the navigation (legacy `onSuccess: toast + navigate` in one handler). */ - RequestedNavigation: { href: Schema.String, replace: Schema.Boolean, toast: Schema.optionalKey(ToastRequest) }, + RequestedNavigation: { route: AppRoute, replace: Schema.Boolean, toast: Schema.optionalKey(ToastRequest) }, RequestedToast: { toast: ToastRequest }, RequestedModal: { modal: ModalRequest }, RequestedCommandPalette: { page: CommandPalettePage }, @@ -39,7 +39,7 @@ export const requestedOrgNavigation = ( orgSlug === null ? undefined : PageOutMessage.RequestedNavigation({ - href: hrefOf(toRoute(orgSlug)), + route: toRoute(orgSlug), replace: options.replace ?? false, ...(options.toast === undefined ? {} : { toast: options.toast }), }) diff --git a/apps/web-foldkit/src/page/root/index.ts b/apps/web-foldkit/src/page/root/index.ts index 809b4b096..549fc67d5 100644 --- a/apps/web-foldkit/src/page/root/index.ts +++ b/apps/web-foldkit/src/page/root/index.ts @@ -4,9 +4,10 @@ import { Effect, Option, Schema, Stream } from "effect" import { Command, Submodel, Subscription } from "foldkit" import { modifyFields } from "foldkit/struct" import { organizationCollection, organizationMemberCollection } from "~/db/collections" -import { takeReturnTo } from "../../auth/return-to" +import { forgetReturnTo, readReturnTo } from "../../auth/return-to" import { liveQueryStream } from "../../data/live-query" import { rootRedirect } from "../../redirect" +import { type AppRoute, routeOfPath } from "../../route" import type { CurrentUser } from "../../session" import { appLoader } from "../../shell/layouts" import { @@ -23,16 +24,25 @@ import { Message, Model } from "./model" type Return = PageReturn<Model, Message> -export const TakeReturnTo = Command.define("TakeReturnTo", { - messages: [Message.TookReturnTo], - execute: takeReturnTo.pipe(Effect.map((returnTo) => Message.TookReturnTo({ returnTo }))), +/** The page sign-in started from; it stays remembered until the redirect goes there. */ +export const ReadReturnTo = Command.define("ReadReturnTo", { + messages: [Message.ReadReturnTo], + execute: readReturnTo.pipe(Effect.map((returnTo) => Message.ReadReturnTo({ returnTo }))), }) -/** Where to go: back to where sign-in started, unless onboarding comes first; else `rootRedirect`. */ -const targetOf = (model: Model, user: CurrentUser): string => +export const ForgetReturnTo = Command.define("ForgetReturnTo", { + messages: [Message.CompletedForgetReturnTo], + execute: forgetReturnTo.pipe(Effect.as(Message.CompletedForgetReturnTo())), +}) + +/** + * Where to go: back to where sign-in started, unless onboarding comes first; else `rootRedirect`. + * Before onboarding the remembered page is kept, so `/` after onboarding still goes back to it. + */ +const targetOf = (model: Model, user: CurrentUser): { readonly route: AppRoute; readonly isReturn: boolean } => model.returnTo && user.isOnboarded - ? model.returnTo - : rootRedirect(user, Option.fromNullishOr(model.membership)) + ? { route: routeOfPath(model.returnTo), isReturn: true } + : { route: rootRedirect(user, Option.fromNullishOr(model.membership)), isReturn: false } export const Reload = Command.define("Reload", { messages: [Message.CompletedReload], @@ -43,19 +53,21 @@ export const Reload = Command.define("Reload", { const needsMembership = (model: Model, user: CurrentUser): boolean => !(model.returnTo && user.isOnboarded) /** Redirects once the user and the remembered page are known, and the membership if it decides. */ -const redirectWhenReady = (model: Model, shared: Shared): Return => - model.hasRedirected || - model.returnTo === undefined || - shared.currentUser === null || - (model.membership === undefined && needsMembership(model, shared.currentUser)) - ? { model } - : { - model: modifyFields(model, { hasRedirected: () => true }), - outMessage: PageOutMessage.RequestedNavigation({ - href: targetOf(model, shared.currentUser), - replace: true, - }), - } +const redirectWhenReady = (model: Model, shared: Shared): Return => { + if ( + model.hasRedirected || + model.returnTo === undefined || + shared.currentUser === null || + (model.membership === undefined && needsMembership(model, shared.currentUser)) + ) + return { model } + const target = targetOf(model, shared.currentUser) + return { + model: modifyFields(model, { hasRedirected: () => true }), + commands: target.isReturn ? [ForgetReturnTo()] : [], + outMessage: PageOutMessage.RequestedNavigation({ route: target.route, replace: true }), + } +} interface MembershipRow { readonly org: { readonly id: OrganizationId; readonly slug?: string | null } @@ -105,7 +117,7 @@ const subscriptions = Subscription.make<PageSubscriptionInput<Model>, Message>() export const init = (): Return => ({ model: { membership: undefined, returnTo: undefined, hasRedirected: false, isStalled: false }, - commands: [TakeReturnTo()], + commands: [ReadReturnTo()], }) export const update = (model: Model, message: Message, shared: Shared): Return => @@ -118,8 +130,9 @@ export const update = (model: Model, message: Message, shared: Shared): Return = StalledMembership: () => ({ model: modifyFields(model, { isStalled: () => true }) }), PressedReload: () => ({ model, commands: [Reload()] }), CompletedReload: () => ({ model }), - TookReturnTo: ({ returnTo }) => + ReadReturnTo: ({ returnTo }) => redirectWhenReady(modifyFields(model, { returnTo: () => returnTo }), shared), + CompletedForgetReturnTo: () => ({ model }), }) export const page = definePage( diff --git a/apps/web-foldkit/src/page/root/model.ts b/apps/web-foldkit/src/page/root/model.ts index 0eaf89b04..28c86aad0 100644 --- a/apps/web-foldkit/src/page/root/model.ts +++ b/apps/web-foldkit/src/page/root/model.ts @@ -20,7 +20,8 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ UpdatedMembership: { membership: Schema.NullOr(RootMembership) }, - TookReturnTo: { returnTo: Schema.NullOr(Schema.String) }, + ReadReturnTo: { returnTo: Schema.NullOr(Schema.String) }, + CompletedForgetReturnTo: {}, StalledMembership: {}, PressedReload: {}, CompletedReload: {}, diff --git a/apps/web-foldkit/src/page/root/story.test.ts b/apps/web-foldkit/src/page/root/story.test.ts index 261872e8b..69fcd97af 100644 --- a/apps/web-foldkit/src/page/root/story.test.ts +++ b/apps/web-foldkit/src/page/root/story.test.ts @@ -1,9 +1,10 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../route" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { currentUser, makeShared, organizationId, storyUpdate } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" -import { init, Reload, TakeReturnTo, update } from "./index" +import { ForgetReturnTo, init, ReadReturnTo, Reload, update } from "./index" import { Message } from "./model" /** `/` forwards once the user, their membership and the page sign-in started from are known. */ @@ -11,11 +12,11 @@ import { Message } from "./model" const membership = { organizationId, slug: "hazel" } const signedIn = storyUpdate(update, makeShared()) const notOnboarded = storyUpdate(update, makeShared({ currentUser: { ...currentUser, isOnboarded: false } })) -const go = (href: string) => expectOutMessage(PageOutMessage.RequestedNavigation({ href, replace: true })) +const go = (href: string) => expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(href), replace: true })) describe("root redirect", () => { test("reads the remembered page first", () => { - expect(init().commands?.map((command) => command.name)).toEqual([TakeReturnTo.name]) + expect(init().commands?.map((command) => command.name)).toEqual([ReadReturnTo.name]) }) test("without a remembered page it goes to the user's organization", () => { @@ -24,7 +25,7 @@ describe("root redirect", () => { given(init().model), message(Message.UpdatedMembership({ membership })), Command.expectNone(), - message(Message.TookReturnTo({ returnTo: null })), + message(Message.ReadReturnTo({ returnTo: null })), go("/hazel"), ) }) @@ -33,8 +34,11 @@ describe("root redirect", () => { story( signedIn, given(init().model), - message(Message.TookReturnTo({ returnTo: "/join/acme" })), + message(Message.ReadReturnTo({ returnTo: "/join/acme" })), go("/join/acme"), + // Only now, with the redirect going there, is the remembered page used up. + Command.expectExact(ForgetReturnTo()), + Command.resolve(ForgetReturnTo, Message.CompletedForgetReturnTo()), ) }) @@ -42,7 +46,7 @@ describe("root redirect", () => { story( signedIn, given(init().model), - message(Message.TookReturnTo({ returnTo: null })), + message(Message.ReadReturnTo({ returnTo: null })), message(Message.StalledMembership()), model((m) => expect(m.isStalled).toBe(true)), message(Message.PressedReload()), @@ -58,9 +62,11 @@ describe("root redirect", () => { story( notOnboarded, given(init().model), - message(Message.TookReturnTo({ returnTo: "/join/acme" })), + message(Message.ReadReturnTo({ returnTo: "/join/acme" })), message(Message.UpdatedMembership({ membership: null })), go("/onboarding"), + // The remembered page stays for after onboarding, when `/` runs this redirect again. + Command.expectNone(), ) }) }) diff --git a/apps/web-foldkit/src/page/select-organization/scene.test.ts b/apps/web-foldkit/src/page/select-organization/scene.test.ts index d9458c149..2a394cf60 100644 --- a/apps/web-foldkit/src/page/select-organization/scene.test.ts +++ b/apps/web-foldkit/src/page/select-organization/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../route" import { OrganizationId } from "@hazel/schema" import { Schema } from "effect" import * as Scene from "foldkit/scene" @@ -32,10 +33,10 @@ describe("select organization view", () => { Scene.expect(Scene.text("@hazel")).toExist(), Scene.click(Scene.role("button", { name: /Acme Corp/ })), Scene.expectOutMessage( - PageOutMessage.RequestedNavigation({ href: `/onboarding/setup-organization?orgId=${acme.id}`, replace: false }), + PageOutMessage.RequestedNavigation({ route: routeOfPath(`/onboarding/setup-organization?orgId=${acme.id}`), replace: false }), ), Scene.click(Scene.role("button", { name: "Create a new one" })), - Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/onboarding", replace: false })), + Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath("/onboarding"), replace: false })), ) }) @@ -45,7 +46,7 @@ describe("select organization view", () => { Scene.given(init().model), Scene.expect(Scene.role("heading", { name: "Select an organization" })).toBeAbsent(), Scene.Subscription.emit(Message.UpdatedOrganizations({ organizations: [hazel] })), - Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/hazel", replace: false })), + Scene.expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath("/hazel"), replace: false })), Scene.expect(Scene.role("button", { name: /Hazel Labs/ })).toBeAbsent(), ) }) diff --git a/apps/web-foldkit/src/page/select-organization/story.test.ts b/apps/web-foldkit/src/page/select-organization/story.test.ts index 68be93203..67ca8cbfb 100644 --- a/apps/web-foldkit/src/page/select-organization/story.test.ts +++ b/apps/web-foldkit/src/page/select-organization/story.test.ts @@ -7,7 +7,7 @@ import { uuid } from "../../test/pages-fixtures" import { PageOutMessage } from "../out-message" import { Message } from "./message" import type { UserOrganization } from "./model" -import { organizationHref } from "../../route" +import { organizationHref, routeOfPath } from "../../route" import { init, update } from "./update" /** `/select-organization`: a single organization redirects once; otherwise the user picks. */ @@ -21,7 +21,7 @@ const org = (n: number, slug: string | null): UserOrganization => ({ }) const hazel = org(1, "hazel") const unfinished = org(2, null) -const navigation = (href: string) => PageOutMessage.RequestedNavigation({ href, replace: false }) +const navigation = (href: string) => PageOutMessage.RequestedNavigation({ route: routeOfPath(href), replace: false }) describe("select organization", () => { test("a single organization redirects once, even when the live query re-emits", () => { diff --git a/apps/web-foldkit/src/page/select-organization/update.ts b/apps/web-foldkit/src/page/select-organization/update.ts index 7ad9c9091..093b5b6d8 100644 --- a/apps/web-foldkit/src/page/select-organization/update.ts +++ b/apps/web-foldkit/src/page/select-organization/update.ts @@ -2,7 +2,7 @@ import { Option } from "effect" import { Command } from "foldkit" import * as CreateWorkspace from "../create-workspace" import { modifyFields } from "foldkit/struct" -import { AppRoute, hrefOf, organizationHref } from "../../route" +import { AppRoute, organizationRoute } from "../../route" import type { PageReturn } from "../contract" import { PageOutMessage } from "../out-message" import { Message } from "./message" @@ -10,9 +10,9 @@ import type { Model } from "./model" type Return = PageReturn<Model, Message> -const navigate = (model: Model, href: string): Return => ({ +const navigate = (model: Model, route: AppRoute): Return => ({ model, - outMessage: PageOutMessage.RequestedNavigation({ href, replace: false }), + outMessage: PageOutMessage.RequestedNavigation({ route, replace: false }), }) export const init = (): Return => ({ @@ -26,12 +26,12 @@ export const update = (model: Model, message: Message): Return => const [single] = organizations // `<Navigate>` with a single organization: there is nothing to pick. return organizations.length === 1 && single && !model.hasRedirected - ? navigate(modifyFields(next, { hasRedirected: () => true }), organizationHref(single)) + ? navigate(modifyFields(next, { hasRedirected: () => true }), organizationRoute(single)) : { model: next } }, - ClickedOrganization: ({ organization }) => navigate(model, organizationHref(organization)), + ClickedOrganization: ({ organization }) => navigate(model, organizationRoute(organization)), ClickedCreateNew: () => - navigate(model, hrefOf(AppRoute.Onboarding({ orgId: Option.none(), step: Option.none() }))), + navigate(model, AppRoute.Onboarding({ orgId: Option.none(), step: Option.none() })), GotCreateWorkspaceMessage: ({ message: child }) => { const result = CreateWorkspace.update(model.createWorkspace, child) return { diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts index eb3abe40a..95d1005f1 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import { ChannelId, ExternalChannelId, SyncChannelLinkId } from "@hazel/schema" import { Schema } from "effect" import * as Scene from "foldkit/scene" @@ -79,7 +80,7 @@ describe("connection states", () => { Scene.expect(Scene.text("Connection not found")).toExist(), Scene.click(Scene.role("button", { name: "Go back" })), Scene.expectOutMessage( - PageOutMessage.RequestedNavigation({ href: "/hazel/settings/chat-sync", replace: false }), + PageOutMessage.RequestedNavigation({ route: routeOfPath("/hazel/settings/chat-sync"), replace: false }), ), ) }) @@ -111,7 +112,7 @@ describe("disconnect", () => { Scene.Command.resolve(DisconnectConnection, Message.SucceededDisconnect()), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/chat-sync", + route: routeOfPath("/hazel/settings/chat-sync"), replace: false, toast: successToast("Connection deleted"), }), diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts index 490f5efc1..66e335bdb 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts @@ -1,3 +1,4 @@ +import { routeOfPath } from "../../../route" import "../../channel-settings/document-stub" import { ChannelId, @@ -102,7 +103,7 @@ describe("chat sync connection", () => { Command.resolve(DisconnectConnection, Message.SucceededDisconnect()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/chat-sync", + route: routeOfPath("/hazel/settings/chat-sync"), replace: false, toast: { intent: "success", title: "Connection deleted", description: null }, }), diff --git a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts index 24715d5e6..f53a1d825 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { errorToast, successToast } from "../../../data/actions" @@ -83,7 +84,7 @@ describe("list states", () => { Scene.click(Scene.text("Hazel Community")), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ - href: `/hazel/settings/chat-sync/${syncConnectionId}`, + route: routeOfPath(`/hazel/settings/chat-sync/${syncConnectionId}`), replace: false, }), ), @@ -237,7 +238,7 @@ describe("connect a Discord server", () => { Scene.click(Scene.role("button", { name: "Open Discord Integration" })), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/discord", + route: routeOfPath("/hazel/settings/integrations/discord"), replace: false, }), ), diff --git a/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts index 86a65f20e..678281e56 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts @@ -1,3 +1,4 @@ +import { routeOfPath } from "../../../route" import "../../channel-settings/document-stub" import { OrganizationId, SyncConnectionId } from "@hazel/schema" import { Schema } from "effect" @@ -268,14 +269,14 @@ describe("chat sync guards", () => { message(Message.ClickedConnection({ connectionId })), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: `/hazel/settings/chat-sync/${connectionId}`, + route: routeOfPath(`/hazel/settings/chat-sync/${connectionId}`), replace: false, }), ), message(Message.ClickedOpenDiscordIntegration()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/discord", + route: routeOfPath("/hazel/settings/integrations/discord"), replace: false, }), ), diff --git a/apps/web-foldkit/src/page/settings/debug/index.ts b/apps/web-foldkit/src/page/settings/debug/index.ts index 3e6922deb..225f3c966 100644 --- a/apps/web-foldkit/src/page/settings/debug/index.ts +++ b/apps/web-foldkit/src/page/settings/debug/index.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { Submodel } from "foldkit" -import { AppRoute, hrefOf } from "../../../route" +import { AppRoute } from "../../../route" import { definePage, type PageReturn, type PageViewInputs } from "../../contract" import { PageOutMessage } from "../../out-message" @@ -24,7 +24,7 @@ export const page = definePage( ? { model: {}, outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf(AppRoute.SettingsGeneral({ orgSlug: route.orgSlug })), + route: AppRoute.SettingsGeneral({ orgSlug: route.orgSlug }), replace: true, }), } diff --git a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts index 3fccbb006..82f32599f 100644 --- a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts +++ b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { @@ -136,7 +137,7 @@ describe("delete workspace", () => { Scene.Command.resolve(DeleteOrganization, Message.SucceededDeleteWorkspace()), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/", + route: routeOfPath("/"), replace: false, toast: successToast("Workspace deleted successfully"), }), diff --git a/apps/web-foldkit/src/page/settings/general/story.test.ts b/apps/web-foldkit/src/page/settings/general/story.test.ts index 387befcff..cbd1338f3 100644 --- a/apps/web-foldkit/src/page/settings/general/story.test.ts +++ b/apps/web-foldkit/src/page/settings/general/story.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../route" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { errorToast, successToast } from "../../../data/actions" @@ -189,7 +190,7 @@ describe("delete workspace", () => { Command.resolve(DeleteOrganization, Message.SucceededDeleteWorkspace()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/", + route: routeOfPath("/"), replace: false, toast: successToast("Workspace deleted successfully"), }), diff --git a/apps/web-foldkit/src/page/settings/general/update.ts b/apps/web-foldkit/src/page/settings/general/update.ts index a238f5dce..f6eafd3b6 100644 --- a/apps/web-foldkit/src/page/settings/general/update.ts +++ b/apps/web-foldkit/src/page/settings/general/update.ts @@ -7,7 +7,7 @@ import { setPublicModeAction, updateOrganizationAction } from "~/db/actions" import type { ToastRequest } from "../../../overlay/toasts" import * as Modal from "../../../ui/modal" import { HazelRpc } from "../../../rpc" -import type { RouteOf } from "../../../route" +import { AppRoute, type RouteOf } from "../../../route" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import { errorToast, failureToast, settle, successToast } from "../../../data/actions" @@ -294,7 +294,7 @@ export const update = (model: Model, message: Message, shared: Shared): Return = return { ...closed, outMessage: PageOutMessage.RequestedNavigation({ - href: "/", + route: AppRoute.Root(), replace: false, toast: successToast("Workspace deleted successfully"), }), diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts index 0b5f8400b..3db9eaed3 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../../route" import { OrganizationId } from "@hazel/schema" import { Option, Schema } from "effect" import { @@ -57,7 +58,7 @@ describe("integration detail page", () => { const page = pageFor("linear", { status: "error", errorCode: "token_exchange_failed" }) expect(page.outMessage).toEqual( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/linear", + route: routeOfPath("/hazel/settings/integrations/linear"), replace: true, toast: errorToast( "Failed to connect to Linear", @@ -133,7 +134,7 @@ describe("integration detail page", () => { const page = pageFor("linear", { status: "success" }) expect(page.outMessage).toEqual( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/linear", + route: routeOfPath("/hazel/settings/integrations/linear"), replace: true, toast: successToast("Connected to Linear", "Your account has been successfully connected."), }), @@ -152,7 +153,7 @@ describe("integration detail page", () => { given(pageFor("linear").model), click(role("button", { name: "Back to integrations" })), expectOutMessage( - PageOutMessage.RequestedNavigation({ href: "/hazel/settings/integrations", replace: false }), + PageOutMessage.RequestedNavigation({ route: routeOfPath("/hazel/settings/integrations"), replace: false }), ), ) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/update.ts b/apps/web-foldkit/src/page/settings/integrations/detail/update.ts index 2d37c858f..c69c916f9 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/update.ts @@ -1,6 +1,6 @@ import { Option, Schema } from "effect" import { getIntegrationById } from "~/lib/integrations/__data" -import { AppRoute, hrefOf, integrationHref, type RouteOf } from "../../../../route" +import { AppRoute, integrationRoute, type RouteOf } from "../../../../route" import type { PageReturn, Shared } from "../../../contract" import { PageOutMessage } from "../../../out-message" import { errorToast, successToast } from "../../../../data/actions" @@ -62,7 +62,7 @@ const oauthCallback = (model: Model, route: Route): Return => { return { model: { ...model, pendingVerification: model.pendingVerification || status === "success" }, outMessage: PageOutMessage.RequestedNavigation({ - href: integrationHref(model.orgSlug, model.integrationId), + route: integrationRoute(model.orgSlug, model.integrationId), replace: true, toast: status === "success" @@ -94,7 +94,7 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedBack: () => ({ model, outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf(AppRoute.SettingsIntegrations({ orgSlug: model.orgSlug })), + route: AppRoute.SettingsIntegrations({ orgSlug: model.orgSlug }), replace: false, }), }), diff --git a/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts index 39068edd1..724311b2d 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../../route" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { makeShared, pageScene } from "../../../../test/pages-fixtures" @@ -60,7 +61,7 @@ describe("integrations catalog", () => { Scene.expect(Scene.text("Figma")).toExist(), Scene.click(card("Linear")), Scene.expectOutMessage( - PageOutMessage.RequestedNavigation({ href: "/hazel/settings/integrations/linear", replace: false }), + PageOutMessage.RequestedNavigation({ route: routeOfPath("/hazel/settings/integrations/linear"), replace: false }), ), ) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/index/update.ts b/apps/web-foldkit/src/page/settings/integrations/index/update.ts index aacb6d289..1da7d6305 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/update.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import { HazelRpc } from "../../../../rpc" -import { integrationHref, type RouteOf } from "../../../../route" +import { integrationRoute, type RouteOf } from "../../../../route" import type { PageReturn, Shared } from "../../../contract" import { PageOutMessage } from "../../../out-message" import { Message } from "./message" @@ -48,7 +48,7 @@ export const update = (model: Model, message: Message): PageReturn<Model, Messag ClickedIntegration: ({ integrationId }) => ({ model, outMessage: PageOutMessage.RequestedNavigation({ - href: integrationHref(model.orgSlug, integrationId), + route: integrationRoute(model.orgSlug, integrationId), replace: false, }), }), diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts index 17c6c15d0..f8bb30b40 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { routeOfPath } from "../../../../route" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { failureToastFixture, makeShared, pageScene } from "../../../../test/pages-fixtures" @@ -25,7 +26,7 @@ describe("installed apps", () => { Scene.click(Scene.role("button", { name: "Browse Marketplace" })), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/marketplace", + route: routeOfPath("/hazel/settings/integrations/marketplace"), replace: false, }), ), diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts index 025458b34..70a40f1b2 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts @@ -1,3 +1,4 @@ +import { routeOfPath } from "../../../../route" import { BotId } from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, story } from "foldkit/story" @@ -53,7 +54,7 @@ describe("installed apps outcomes", () => { message(Message.ClickedBrowseMarketplace()), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: "/hazel/settings/integrations/marketplace", + route: routeOfPath("/hazel/settings/integrations/marketplace"), replace: false, }), ), diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/update.ts b/apps/web-foldkit/src/page/settings/integrations/installed/update.ts index acef97af2..db2d1772f 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/update.ts @@ -3,7 +3,7 @@ import { Effect, Exit } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import { HazelRpc } from "../../../../rpc" -import { AppRoute, hrefOf, type RouteOf } from "../../../../route" +import { AppRoute, type RouteOf } from "../../../../route" import type { PageReturn } from "../../../contract" import { PageOutMessage } from "../../../out-message" import { failureToast, rateLimitMessage, successToast } from "../../../../data/actions" @@ -66,7 +66,7 @@ export const update = (model: Model, message: Message): Return => ClickedBrowseMarketplace: () => ({ model, outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf(AppRoute.SettingsIntegrationsMarketplace({ orgSlug: model.orgSlug })), + route: AppRoute.SettingsIntegrationsMarketplace({ orgSlug: model.orgSlug }), replace: false, }), }), diff --git a/apps/web-foldkit/src/platform/desktop-auth.ts b/apps/web-foldkit/src/platform/desktop-auth.ts index 351fba1fe..cecdb5301 100644 --- a/apps/web-foldkit/src/platform/desktop-auth.ts +++ b/apps/web-foldkit/src/platform/desktop-auth.ts @@ -3,7 +3,7 @@ import { type DesktopAuth, layerVault, makeDesktopAuth } from "@hazel/auth/deskt import { getTauriCore, getTauriDeepLink, getTauriOpener } from "@hazel/desktop/bridge" import { BrowserLogin } from "@yielded/auth" import { layerCryptoWeb } from "@yielded/crypto/WebCrypto" -import { Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { Context, Effect, Exit, Layer, ManagedRuntime, Option, Schema, SynchronizedRef } from "effect" import { FetchHttpClient } from "effect/http" /** @@ -15,25 +15,19 @@ import { FetchHttpClient } from "effect/http" const unavailable = (reason: "unavailable" | "storage" = "unavailable") => BrowserLogin.PlatformError.make({ reason }) +/** A Tauri command on the keychain vault; outside Tauri the storage is unavailable. */ +const invokeVault = <A>(command: string, args?: Record<string, unknown>) => + Effect.suspend(() => { + const core = getTauriCore() + return core === undefined + ? Effect.fail(unavailable("storage")) + : Effect.tryPromise({ try: () => core.invoke<A>(command, args), catch: () => unavailable("storage") }) + }) + /** One JSON record in the OS keychain; the Rust side never interprets it. */ const KeychainStore = { - read: Effect.tryPromise({ - try: async () => { - const core = getTauriCore() - if (core === undefined) throw new Error("Tauri core unavailable") - return await core.invoke<string | null>("auth_vault_read") - }, - catch: () => unavailable("storage"), - }), - write: (record: string) => - Effect.tryPromise({ - try: async () => { - const core = getTauriCore() - if (core === undefined) throw new Error("Tauri core unavailable") - await core.invoke("auth_vault_write", { record }) - }, - catch: () => unavailable("storage"), - }), + read: invokeVault<string | null>("auth_vault_read"), + write: (record: string) => invokeVault<void>("auth_vault_write", { record }), } /** Only the registered callback, with exactly its query; anything else is ignored. */ @@ -58,13 +52,23 @@ export const BrowserLive = Layer.effect( } } + // Without the deep link listener no callback can arrive: `open` then fails at once instead of + // waiting out its timeout, and the session itself still reads from the vault. const deepLink = getTauriDeepLink() - if (deepLink !== undefined) { - yield* Effect.acquireRelease( - Effect.promise(() => deepLink.onOpenUrl(capture)), + const isListening = + deepLink !== undefined && + (yield* Effect.acquireRelease( + Effect.tryPromise({ try: () => deepLink.onOpenUrl(capture), catch: () => unavailable() }), (unlisten) => Effect.sync(unlisten), - ) - const initial = yield* Effect.promise(() => deepLink.getCurrent()).pipe( + ).pipe( + Effect.as(true), + Effect.catchTag("BrowserLoginPlatformError", () => + Effect.logWarning("[desktop-auth] deep link listener unavailable").pipe(Effect.as(false)), + ), + )) + if (deepLink !== undefined && isListening) { + // Best effort: a cold-start callback is a convenience; sign-in can always be started again. + const initial = yield* Effect.tryPromise({ try: () => deepLink.getCurrent(), catch: () => unavailable() }).pipe( Effect.orElseSucceed(() => null), ) if (initial !== null) capture(initial) @@ -73,6 +77,7 @@ export const BrowserLive = Layer.effect( return BrowserLogin.Browser.of({ open: ({ url }) => Effect.gen(function* () { + if (!isListening) return yield* Effect.fail(unavailable()) const json = yield* KeychainStore.read if (json === null) return yield* Effect.fail(unavailable("storage")) const record = yield* Schema.decodeEffect( @@ -145,19 +150,48 @@ const DesktopAuthLive = Layer.effect( Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { credentials: "omit", redirect: "error" })), ) -/** Built on first use and kept for the app's life (the deep link listener lives with it). */ -let runtime: ManagedRuntime.ManagedRuntime<DesktopAuthService, unknown> | undefined +/** The desktop auth runtime could not be built (keychain, crypto or deep link setup failed). */ +export class DesktopAuthUnavailableError extends Schema.TaggedError<DesktopAuthUnavailableError>()( + "DesktopAuthUnavailableError", + { cause: Schema.Defect() }, +) {} + +type DesktopAuthRuntime = ManagedRuntime.ManagedRuntime<DesktopAuthService, unknown> -export const runDesktopAuth = <A, E>(f: (auth: DesktopAuth) => Effect.Effect<A, E>): Effect.Effect<A, E> => +/** + * Built on first use and kept for the app's life (the deep link listener lives with it). Only a + * runtime that built is kept: after a failed build the next use tries again. + */ +const desktopAuthRuntime = SynchronizedRef.makeUnsafe(Option.none<DesktopAuthRuntime>()) + +const buildRuntime: Effect.Effect<readonly [DesktopAuth, DesktopAuthRuntime], DesktopAuthUnavailableError> = Effect.suspend(() => { - runtime ??= ManagedRuntime.make(DesktopAuthLive) - const current = runtime - return Effect.flatMap( - Effect.promise(() => current.runPromise(Effect.service(DesktopAuthService))), - f, + const runtime: DesktopAuthRuntime = ManagedRuntime.make(DesktopAuthLive) + return runtime.contextEffect.pipe( + Effect.map((context) => [Context.get(context, DesktopAuthService), runtime] as const), + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : runtime.disposeEffect)), + Effect.catchCause((cause) => Effect.fail(new DesktopAuthUnavailableError({ cause }))), ) }) +const desktopAuth: Effect.Effect<DesktopAuth, DesktopAuthUnavailableError> = SynchronizedRef.modifyEffect( + desktopAuthRuntime, + (current) => + Option.match(current, { + onSome: (runtime) => + runtime.contextEffect.pipe( + Effect.map((context) => [Context.get(context, DesktopAuthService), current] as const), + Effect.catchCause((cause) => Effect.fail(new DesktopAuthUnavailableError({ cause }))), + ), + onNone: () => + Effect.map(buildRuntime, ([auth, runtime]) => [auth, Option.some(runtime)] as const), + }), +) + +export const runDesktopAuth = <A, E>( + f: (auth: DesktopAuth) => Effect.Effect<A, E>, +): Effect.Effect<A, E | DesktopAuthUnavailableError> => Effect.flatMap(desktopAuth, f) + /** The session credential for API requests (`Authorization: Bearer`), or `null` signed out. */ export const desktopSessionBearer = (): Promise<string | null> => Effect.runPromise(runDesktopAuth((auth) => auth.credential).pipe(Effect.orElseSucceed(() => null))) diff --git a/apps/web-foldkit/src/platform/desktop-menu.ts b/apps/web-foldkit/src/platform/desktop-menu.ts index e23f04140..339346b70 100644 --- a/apps/web-foldkit/src/platform/desktop-menu.ts +++ b/apps/web-foldkit/src/platform/desktop-menu.ts @@ -13,16 +13,32 @@ const events: ReadonlyArray<readonly [string, DesktopMenuItem]> = [ ["menu-invite", "Invite"], ] -/** Every menu item the user picks; empty outside the desktop app. */ +/** The Tauri host refused a menu event listener. */ +class MenuListenError extends Schema.TaggedError<MenuListenError>()("MenuListenError", { + event: Schema.String, + cause: Schema.Defect(), +}) {} + +/** Every menu item the user picks; empty outside the desktop app. A listener that fails is skipped. */ export const desktopMenuItems: Stream.Stream<DesktopMenuItem> = Stream.callback<DesktopMenuItem>((queue) => - Effect.gen(function* () { + Effect.suspend(() => { const event = getTauriEvent() - if (!isDesktop || event === undefined) return - for (const [name, item] of events) { - yield* Effect.acquireRelease( - Effect.promise(() => event.listen(name, () => Queue.offerUnsafe(queue, item))), - (unlisten) => Effect.sync(unlisten), - ) - } + if (!isDesktop || event === undefined) return Effect.void + return Effect.forEach( + events, + ([name, item]) => + Effect.acquireRelease( + Effect.tryPromise({ + try: () => event.listen(name, () => Queue.offerUnsafe(queue, item)), + catch: (cause) => new MenuListenError({ event: name, cause }), + }), + (unlisten) => Effect.sync(unlisten), + ).pipe( + Effect.catchTag("MenuListenError", (error) => + Effect.logWarning("[desktop-menu] listener unavailable", error.event), + ), + ), + { discard: true }, + ) }).pipe(Effect.andThen(Effect.never)), ) diff --git a/apps/web-foldkit/src/platform/index.ts b/apps/web-foldkit/src/platform/index.ts index 29507afdd..169553281 100644 --- a/apps/web-foldkit/src/platform/index.ts +++ b/apps/web-foldkit/src/platform/index.ts @@ -1,5 +1,6 @@ -import { Effect, Option, Schema } from "effect" -import { Command, ManagedResource, Subscription, type Update } from "foldkit" +import type { ChannelId } from "@hazel/schema" +import { Option, Schema } from "effect" +import { Subscription, Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import type { HazelRpc } from "../rpc" @@ -7,53 +8,46 @@ import * as PresenceMessage from "./presence/message" import * as PresenceModel from "./presence/model" import * as PresenceSubscription from "./presence/subscription" import * as PresenceUpdate from "./presence/update" -import { acquireRivetClient, RivetClient, RivetClientLoadError } from "./rivet" -/** App-wide background work the legacy providers did: presence and the Rivet client. */ - -export const RivetStatus = Schema.Literals(["Connecting", "Ready", "Failed"]) +/** App-wide background work the legacy providers did: presence (`PresenceProvider`). */ export const Model = Schema.Struct({ presence: PresenceModel.Model, - rivet: RivetStatus, }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotPresenceMessage: { message: PresenceMessage.Message }, - AcquiredRivetClient: {}, - ReleasedRivetClient: {}, - FailedAcquireRivetClient: { reason: Schema.String }, }) export type Message = typeof Message.Type -export const init = (): Model => ({ presence: PresenceModel.init(), rivet: "Connecting" }) +export const init = (): Model => ({ presence: PresenceModel.init() }) type Return = Update.Return<Model, Message, HazelRpc> -const withPresence = (model: Model, result: PresenceUpdate.Return): Return => ({ - model: modifyFields(model, { presence: () => result.model }), - commands: Command.mapMessages(result.commands, (child) => Message.GotPresenceMessage({ message: child })), +const foldPresence = Update.foldChild({ + update: (presence: PresenceModel.Model, step: (presence: PresenceModel.Model) => PresenceUpdate.Return) => + step(presence), + read: (model: Model) => Option.some(model.presence), + write: (model, presence) => modifyFields(model, { presence: () => presence }), + toParentMessage: (message: PresenceMessage.Message) => Message.GotPresenceMessage({ message }), }) /** The user picked a status in the command palette (`usePresence().setStatus`). */ export const pickPresenceStatus = (model: Model, status: PresenceModel.PresenceStatus): Return => - withPresence(model, PresenceUpdate.pickStatus(model.presence, status)) + foldPresence(model, (presence) => PresenceUpdate.pickStatus(presence, status)) export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { GotPresenceMessage: ({ message: inner }) => - withPresence(model, PresenceUpdate.update(model.presence, inner)), - AcquiredRivetClient: () => ({ model: modifyFields(model, { rivet: () => "Ready" }) }), - ReleasedRivetClient: () => ({ model: modifyFields(model, { rivet: () => "Connecting" }) }), - FailedAcquireRivetClient: () => ({ model: modifyFields(model, { rivet: () => "Failed" }) }), + foldPresence(model, (presence) => PresenceUpdate.update(presence, inner)), }) -/** What the root tells the platform: the org layout's user (null outside it) and the pathname. */ +/** What the root tells the platform: the org layout's user (null outside it) and the route's channel. */ export interface Input { readonly model: Model readonly userId: PresenceSubscription.Input["userId"] - readonly pathname: string + readonly activeChannelId: ChannelId | null } export const subscriptions = Subscription.lift(PresenceSubscription.subscriptions)<Input, Message>({ @@ -61,23 +55,7 @@ export const subscriptions = Subscription.lift(PresenceSubscription.subscription Option.some({ model: input.model.presence, userId: input.userId, - activeChannelId: input.userId === null ? null : PresenceModel.channelIdOfPathname(input.pathname), + activeChannelId: input.userId === null ? null : input.activeChannelId, }), toParentMessage: (message) => Message.GotPresenceMessage({ message }), }) - -export const managedResources = ManagedResource.make<Model, Message>()((entry) => ({ - // Legacy creates the client when its module loads: on every route, signed in or not. - rivetClient: entry(Schema.Option(Schema.Null), { - resource: RivetClient, - modelToMaybeRequirements: () => Option.some(null), - acquire: () => acquireRivetClient, - release: () => Effect.void, - onAcquired: () => Message.AcquiredRivetClient(), - onReleased: () => Message.ReleasedRivetClient(), - onAcquireError: (error) => - Message.FailedAcquireRivetClient({ - reason: error instanceof RivetClientLoadError ? error.message : String(error), - }), - }), -})) diff --git a/apps/web-foldkit/src/platform/presence/message.ts b/apps/web-foldkit/src/platform/presence/message.ts index 130a246b7..b7a1b1a8b 100644 --- a/apps/web-foldkit/src/platform/presence/message.ts +++ b/apps/web-foldkit/src/platform/presence/message.ts @@ -1,6 +1,8 @@ import { ChannelId, UserId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" +import { Sent } from "./model" export const Message = defineMessageUnion({ /** The org layout's user and route channel, as of `nowMs` (null user: outside the layout). */ @@ -16,11 +18,14 @@ export const Message = defineMessageUnion({ /** The AFK deadline of the latest activity passed. */ ReachedAfkTimeout: { nowMs: Schema.Number }, ElapsedSyncDebounce: { version: Schema.Number }, - SucceededSendPresenceUpdate: {}, - FailedSendPresenceUpdate: { reason: Schema.String }, + /** The server confirmed the write for `userId`; it now holds `sent`. */ + SucceededSendPresenceUpdate: { userId: UserId, sent: Sent }, + FailedSendPresenceUpdate: { userId: UserId, reason: CommonReason }, + /** The backoff after a failed write ran out: sync again. */ + ElapsedSyncRetry: { userId: UserId }, TickedHeartbeat: {}, SucceededSendHeartbeat: {}, - FailedSendHeartbeat: { reason: Schema.String }, + FailedSendHeartbeat: { reason: CommonReason }, CompletedBroadcastActivity: {}, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/platform/presence/model.ts b/apps/web-foldkit/src/platform/presence/model.ts index 21ec42c57..8ed1626eb 100644 --- a/apps/web-foldkit/src/platform/presence/model.ts +++ b/apps/web-foldkit/src/platform/presence/model.ts @@ -1,5 +1,5 @@ import { ChannelId, UserId } from "@hazel/schema" -import { Duration, Option, Schema } from "effect" +import { Duration, Schema } from "effect" /** Port of `hooks/use-presence.ts`: the signed-in user's own status, AFK and heartbeat. */ @@ -7,12 +7,15 @@ export const AFK_TIMEOUT_MS = Duration.toMillis(Duration.minutes(15)) export const HEARTBEAT_INTERVAL = Duration.seconds(15) export const ACTIVITY_THROTTLE_MS = 1_000 export const SYNC_DEBOUNCE = Duration.millis(300) +/** After a failed write the sync runs again after this long, doubling per failure up to the cap. */ +export const SYNC_RETRY_BASE_MS = 2_000 +export const SYNC_RETRY_MAX_MS = 60_000 /** The statuses a user can pick (legacy `PresenceStatus` in the palette's `StatusView`). */ export const PresenceStatus = Schema.Literals(["online", "away", "busy", "dnd"]) export type PresenceStatus = typeof PresenceStatus.Type -/** What was last written with `userPresenceStatus.update` (legacy `previousValuesRef`). */ +/** What the server holds after a `userPresenceStatus.update` it confirmed (legacy `previousValuesRef`). */ export const Sent = Schema.Struct({ status: PresenceStatus, activeChannelId: Schema.NullOr(ChannelId), @@ -29,8 +32,10 @@ export const Model = Schema.Struct({ isAfk: Schema.Boolean, /** `manualStatusAtom`: a status picked in the palette, ahead of the AFK-derived one. */ manualStatus: Schema.NullOr(PresenceStatus), - /** Null until the initial update of this mount has been written. */ + /** The last write the server confirmed; null until this mount's first write lands. */ sent: Schema.NullOr(Sent), + /** Writes that failed in a row; each failure re-syncs after a growing backoff. */ + failedSyncs: Schema.Number, /** Bumped on every change; the debounce Subscription restarts on each bump. */ syncVersion: Schema.Number, isSyncPending: Schema.Boolean, @@ -45,6 +50,7 @@ export const init = (): Model => ({ isAfk: false, manualStatus: null, sent: null, + failedSyncs: 0, syncVersion: 0, isSyncPending: false, isHeartbeatInFlight: false, @@ -56,22 +62,16 @@ export const computedStatusOf = (model: Model): PresenceStatus => export const isAfkAt = (lastActivityMs: number, nowMs: number) => nowMs - lastActivityMs >= AFK_TIMEOUT_MS -const decodeChannelId = Schema.decodeUnknownOption(ChannelId) - -/** `currentChannelIdAtom`: the segment after `chat` in `/$orgSlug/chat/$id/...`. */ -export const channelIdOfPathname = (pathname: string): ChannelId | null => { - const segments = pathname.split("/") - const chatIndex = segments.indexOf("chat") - const segment = chatIndex !== -1 && chatIndex < segments.length - 1 ? segments[chatIndex + 1] : undefined - return segment === undefined ? null : Option.getOrNull(decodeChannelId(segment)) -} +/** The backoff before re-syncing after `failedSyncs` failed writes in a row. */ +export const syncRetryDelayMs = (failedSyncs: number) => + Math.min(SYNC_RETRY_MAX_MS, SYNC_RETRY_BASE_MS * 2 ** Math.max(0, failedSyncs - 1)) export type UpdatePayload = { readonly status?: PresenceStatus readonly activeChannelId?: ChannelId | null } -/** The first write of a mount carries both fields; later ones only what changed (or nothing). */ +/** The first write of a mount carries both fields; later ones only what changed since the last confirmed one. */ export const payloadOf = (model: Model): UpdatePayload | null => { const status = computedStatusOf(model) const { sent, activeChannelId } = model diff --git a/apps/web-foldkit/src/platform/presence/update.ts b/apps/web-foldkit/src/platform/presence/update.ts index 6ad755edc..f7cfb3d5b 100644 --- a/apps/web-foldkit/src/platform/presence/update.ts +++ b/apps/web-foldkit/src/platform/presence/update.ts @@ -1,32 +1,40 @@ -import { ChannelId } from "@hazel/schema" -import { Effect, Schema } from "effect" +import { ChannelId, UserId } from "@hazel/schema" +import { Duration, Effect, Schema } from "effect" import { Command, type Update } from "foldkit" import { modifyFields } from "foldkit/struct" +import { catchCommon } from "../../data/actions" import { HazelRpc } from "../../rpc" import { broadcastActivity } from "./activity" import { Message } from "./message" -import { computedStatusOf, isAfkAt, type Model, payloadOf, PresenceStatus } from "./model" +import { computedStatusOf, isAfkAt, type Model, payloadOf, PresenceStatus, Sent, syncRetryDelayMs } from "./model" export type Return = Update.Return<Model, Message, HazelRpc> // COMMANDS +/** Writes `status` and/or `activeChannelId`; on success the server holds `snapshot`. */ export const SendPresenceUpdate = Command.define("SendPresenceUpdate", { args: { + userId: UserId, + snapshot: Sent, status: Schema.optionalKey(PresenceStatus), activeChannelId: Schema.optionalKey(Schema.NullOr(ChannelId)), }, messages: [Message.SucceededSendPresenceUpdate, Message.FailedSendPresenceUpdate], - execute: (payload) => + execute: ({ userId, snapshot, ...payload }) => Effect.gen(function* () { const client = yield* HazelRpc yield* client("userPresenceStatus.update", payload) - return Message.SucceededSendPresenceUpdate() - }).pipe( - Effect.catch((error) => - Effect.succeed(Message.FailedSendPresenceUpdate({ reason: String(error) })), - ), - ), + return Message.SucceededSendPresenceUpdate({ userId, sent: snapshot }) + }).pipe(catchCommon((reason) => Message.FailedSendPresenceUpdate({ userId, reason }))), +}) + +/** Waits out the backoff after a failed write. */ +export const WaitSyncRetry = Command.define("WaitSyncRetry", { + args: { userId: UserId, delayMs: Schema.Number }, + messages: [Message.ElapsedSyncRetry], + execute: ({ userId, delayMs }) => + Effect.sleep(Duration.millis(delayMs)).pipe(Effect.as(Message.ElapsedSyncRetry({ userId }))), }) export const SendHeartbeat = Command.define("SendHeartbeat", { @@ -35,7 +43,7 @@ export const SendHeartbeat = Command.define("SendHeartbeat", { const client = yield* HazelRpc yield* client("userPresenceStatus.heartbeat", {}) return Message.SucceededSendHeartbeat() - }).pipe(Effect.catch((error) => Effect.succeed(Message.FailedSendHeartbeat({ reason: String(error) })))), + }).pipe(catchCommon((reason) => Message.FailedSendHeartbeat({ reason }))), }) export const BroadcastActivity = Command.define("BroadcastActivity", { @@ -46,6 +54,9 @@ export const BroadcastActivity = Command.define("BroadcastActivity", { // UPDATE +const bumpSync = (model: Model): Model => + modifyFields(model, { syncVersion: (version) => version + 1, isSyncPending: () => true }) + /** Restarts the debounce on mount (a new user) and whenever the status or channel moved. */ const scheduleSync = (previous: Model, next: Model): Return => { const isDue = @@ -53,8 +64,7 @@ const scheduleSync = (previous: Model, next: Model): Return => { (previous.userId !== next.userId || computedStatusOf(previous) !== computedStatusOf(next) || previous.activeChannelId !== next.activeChannelId) - if (!isDue) return { model: next } - return { model: modifyFields(next, { syncVersion: (version) => version + 1, isSyncPending: () => true }) } + return { model: isDue ? bumpSync(next) : next } } const withActivity = (model: Model, atMs: number): Model => @@ -80,6 +90,7 @@ export const update = (model: Model, message: Message): Return => lastActivityMs: () => lastActivityMs, isAfk: () => isAfkAt(lastActivityMs, nowMs), sent: (sent) => (isSameUser ? sent : null), + failedSyncs: (failed) => (isSameUser ? failed : 0), }), ) }, @@ -97,16 +108,32 @@ export const update = (model: Model, message: Message): Return => if (version !== model.syncVersion || !model.isSyncPending) return { model } const settled = modifyFields(model, { isSyncPending: () => false }) const payload = model.userId === null ? null : payloadOf(model) - if (payload === null) return { model: settled } + if (model.userId === null || payload === null) return { model: settled } return { - model: modifyFields(settled, { - sent: () => ({ status: computedStatusOf(model), activeChannelId: model.activeChannelId }), - }), - commands: [SendPresenceUpdate(payload)], + model: settled, + commands: [ + SendPresenceUpdate({ + userId: model.userId, + snapshot: { status: computedStatusOf(model), activeChannelId: model.activeChannelId }, + ...payload, + }), + ], + } + }, + // `sent` moves only once the server has the write, so a failed one is retried, not forgotten. + SucceededSendPresenceUpdate: ({ userId, sent }) => + userId === model.userId + ? { model: modifyFields(model, { sent: () => sent, failedSyncs: () => 0 }) } + : { model }, + FailedSendPresenceUpdate: ({ userId }) => { + if (userId !== model.userId) return { model } + const failedSyncs = model.failedSyncs + 1 + return { + model: modifyFields(model, { failedSyncs: () => failedSyncs }), + commands: [WaitSyncRetry({ userId, delayMs: syncRetryDelayMs(failedSyncs) })], } }, - SucceededSendPresenceUpdate: () => ({ model }), - FailedSendPresenceUpdate: () => ({ model }), + ElapsedSyncRetry: ({ userId }) => (userId === model.userId ? { model: bumpSync(model) } : { model }), TickedHeartbeat: () => model.isHeartbeatInFlight || model.userId === null ? { model } @@ -115,6 +142,7 @@ export const update = (model: Model, message: Message): Return => commands: [SendHeartbeat()], }, SucceededSendHeartbeat: () => ({ model: modifyFields(model, { isHeartbeatInFlight: () => false }) }), + // The next tick pings again; a missed heartbeat only ages `lastSeenAt` a little. FailedSendHeartbeat: () => ({ model: modifyFields(model, { isHeartbeatInFlight: () => false }) }), CompletedBroadcastActivity: () => ({ model }), }) diff --git a/apps/web-foldkit/src/platform/rivet.ts b/apps/web-foldkit/src/platform/rivet.ts deleted file mode 100644 index 2cd848a87..000000000 --- a/apps/web-foldkit/src/platform/rivet.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Effect, Schema } from "effect" -import { ManagedResource } from "foldkit" - -/** - * The legacy Rivet actor client (`lib/rivet-client.ts`), created at boot like legacy's module-level - * `createClient`: creating it starts the manager metadata lookup. Actor connections (streaming AI - * messages) are opened per message from this client. - */ - -export type RivetClient = (typeof import("~/lib/rivet-client"))["rivetClient"] - -export const RivetClient = ManagedResource.tag<RivetClient>()("RivetClient") - -/** The client chunk failed to load (offline, or a deploy replaced it). */ -export class RivetClientLoadError extends Schema.TaggedError<RivetClientLoadError>()("RivetClientLoadError", { - message: Schema.String, -}) {} - -/** Loaded lazily so the client library stays out of the boot chunk. */ -export const acquireRivetClient: Effect.Effect<RivetClient, RivetClientLoadError> = Effect.tryPromise({ - try: () => import("~/lib/rivet-client"), - catch: (error) => new RivetClientLoadError({ message: String(error) }), -}).pipe(Effect.map((module) => module.rivetClient)) diff --git a/apps/web-foldkit/src/platform/updates.test.ts b/apps/web-foldkit/src/platform/updates.test.ts index 5ed233ac2..ed4486190 100644 --- a/apps/web-foldkit/src/platform/updates.test.ts +++ b/apps/web-foldkit/src/platform/updates.test.ts @@ -24,7 +24,7 @@ describe("desktop updates", () => { ).model expect(progressed).toEqual(Model.Downloading({ downloadedBytes: 5, totalBytes: 10 })) expect(update(progressed, Message.StartedInstall()).model).toEqual(Model.Installing()) - const failed = update(progressed, Message.FailedInstall({ message: "disk full" })).model + const failed = update(progressed, Message.FailedInstall({ reason: "DownloadFailed" })).model expect(isInstalling(failed)).toBe(false) expect(check(failed).model).toEqual(Model.Checking()) }) diff --git a/apps/web-foldkit/src/platform/updates.ts b/apps/web-foldkit/src/platform/updates.ts index 81dbc31af..7b08d4166 100644 --- a/apps/web-foldkit/src/platform/updates.ts +++ b/apps/web-foldkit/src/platform/updates.ts @@ -1,8 +1,9 @@ -import { getTauriProcess, getTauriUpdater, type TauriUpdaterApi } from "@hazel/desktop/bridge" -import { Duration, Effect, Queue, Schema, Stream } from "effect" +import { getTauriProcess, getTauriUpdater } from "@hazel/desktop/bridge" +import { Clock, Duration, Effect, Queue, Schema, Stream } from "effect" import { Command, Subscription, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { defineTaggedUnion } from "foldkit/schema" +import { ToastRequest } from "../overlay/toasts" import { isDesktop } from "./desktop" /** @@ -10,23 +11,28 @@ import { isDesktop } from "./desktop" * and every 6 hours, then download, install and relaunch on request. Web builds never check. */ -type TauriUpdate = NonNullable<Awaited<ReturnType<TauriUpdaterApi["check"]>>> - -/** The update `check` found. It is a live Tauri resource, so it stays out of the Model. */ -let pendingUpdate: TauriUpdate | undefined - export const CHECK_INTERVAL = Duration.hours(6) +/** Why an install did not finish. */ +export const InstallFailure = Schema.Literals([ + "NoUpdate", + "DownloadFailed", + "InstallFailed", + "RestartUnavailable", + "RestartFailed", +]) +export type InstallFailure = typeof InstallFailure.Type + export const Model = defineTaggedUnion({ Idle: {}, Checking: {}, Available: { version: Schema.String, body: Schema.NullOr(Schema.String) }, UpToDate: { checkedAtMs: Schema.Number }, - CheckFailed: { message: Schema.String }, + CheckFailed: {}, Downloading: { downloadedBytes: Schema.Number, totalBytes: Schema.NullOr(Schema.Number) }, Installing: {}, Restarting: {}, - InstallFailed: { message: Schema.String }, + InstallFailed: { reason: InstallFailure }, }) export type Model = typeof Model.Type @@ -36,37 +42,55 @@ export const Message = defineMessageUnion({ TickedUpdateCheck: {}, FoundUpdate: { version: Schema.String, body: Schema.NullOr(Schema.String) }, FoundNoUpdate: { checkedAtMs: Schema.Number }, - FailedUpdateCheck: { message: Schema.String }, + FailedUpdateCheck: {}, ProgressedDownload: { downloadedBytes: Schema.Number, totalBytes: Schema.NullOr(Schema.Number) }, StartedInstall: {}, StartedRelaunch: {}, - FailedInstall: { message: Schema.String }, + FailedInstall: { reason: InstallFailure }, }) export type Message = typeof Message.Type -const messageOf = (error: unknown, fallback: string) => (error instanceof Error ? error.message : fallback) +/** What the update state asks the root for: the toast that follows each step (legacy `TauriUpdateCheck`). */ +export const OutMessage = defineMessageUnion({ + RequestedToast: { toast: ToastRequest }, +}) +export type OutMessage = typeof OutMessage.Type + +// COPY + +const INSTALL_FAILURE_TEXT: { readonly [Reason in InstallFailure]: string } = { + NoUpdate: "No update to install", + DownloadFailed: "Download failed", + InstallFailed: "Installation failed", + RestartUnavailable: "Update installed, but restart is unavailable. Please quit and reopen Hazel manually.", + RestartFailed: + "Update installed, but restart failed and Hazel could not quit automatically. Please quit and reopen Hazel manually.", +} + +export const CHECK_FAILURE_TEXT = "Update check failed" + +export const installFailureText = (reason: InstallFailure): string => INSTALL_FAILURE_TEXT[reason] + +// COMMANDS + +/** The Tauri updater rejected a call. */ +class UpdaterError extends Schema.TaggedError<UpdaterError>()("UpdaterError", { cause: Schema.Defect() }) {} + +const updaterCall = <A>(call: () => Promise<A>) => + Effect.tryPromise({ try: call, catch: (cause) => new UpdaterError({ cause }) }) export const CheckForUpdate = Command.define("CheckForUpdate", { messages: [Message.FoundUpdate, Message.FoundNoUpdate, Message.FailedUpdateCheck], - execute: Effect.suspend(() => { + execute: Effect.gen(function* () { const updater = getTauriUpdater() - if (updater === undefined) return Effect.succeed(Message.FoundNoUpdate({ checkedAtMs: Date.now() })) - return Effect.tryPromise(() => updater.check()).pipe( - Effect.map((update) => { - pendingUpdate = update ?? undefined - return update - ? Message.FoundUpdate({ version: update.version, body: update.body ?? null }) - : Message.FoundNoUpdate({ checkedAtMs: Date.now() }) - }), - Effect.catch((error) => - Effect.succeed( - Message.FailedUpdateCheck({ message: messageOf(error.cause, "Update check failed") }), - ), - ), - ) - }), + const found = updater === undefined ? null : yield* updaterCall(() => updater.check()) + if (found) return Message.FoundUpdate({ version: found.version, body: found.body ?? null }) + return Message.FoundNoUpdate({ checkedAtMs: yield* Clock.currentTimeMillis }) + }).pipe(Effect.catchTag("UpdaterError", () => Effect.succeed(Message.FailedUpdateCheck()))), }) +// UPDATE + const isBusy = (model: Model) => model._tag === "Checking" || model._tag === "Downloading" || @@ -77,75 +101,114 @@ const isBusy = (model: Model) => export const isInstalling = (model: Model) => model._tag === "Downloading" || model._tag === "Installing" || model._tag === "Restarting" -type Return = Update.Return<Model, Message> +export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage> + +const TOAST_ID = "tauri-update" + +const megabytes = (bytes: number) => (bytes / 1024 / 1024).toFixed(1) + +/** The toast for a change of update state, if it needs one. */ +const toastOf = (previous: Model, next: Model): ToastRequest | undefined => + Model.match(next, { + Idle: () => undefined, + Checking: () => undefined, + UpToDate: () => undefined, + CheckFailed: () => undefined, + Available: ({ version, body }): ToastRequest | undefined => + previous._tag === "Available" + ? undefined + : { + intent: "info", + title: `Update available: v${version}`, + description: body ?? "A new version is ready to install", + id: TOAST_ID, + isSticky: true, + action: { label: "Install & Restart", key: "InstallUpdate" }, + }, + Downloading: ({ downloadedBytes, totalBytes }): ToastRequest => ({ + intent: "loading", + title: + totalBytes === null + ? `Downloading update... ${megabytes(downloadedBytes)} MB` + : `Downloading update... ${Math.round((downloadedBytes / totalBytes) * 100)}%`, + description: null, + id: TOAST_ID, + }), + Installing: (): ToastRequest => ({ intent: "loading", title: "Installing update...", description: null, id: TOAST_ID }), + Restarting: (): ToastRequest => ({ intent: "loading", title: "Restarting...", description: null, id: TOAST_ID }), + InstallFailed: ({ reason }): ToastRequest | undefined => + previous._tag === "InstallFailed" + ? undefined + : { intent: "error", title: "Update failed", description: installFailureText(reason), id: TOAST_ID }, + }) + +/** The next state, with the toast it calls for. */ +const transition = (previous: Model, next: Model, commands: Return["commands"] = []): Return => { + const toast = toastOf(previous, next) + return toast === undefined + ? { model: next, commands } + : { model: next, commands, outMessage: OutMessage.RequestedToast({ toast }) } +} /** Check now (the menu item, the settings button, the timer); ignored while busy. */ export const check = (model: Model): Return => - isBusy(model) ? { model } : { model: Model.Checking(), commands: [CheckForUpdate()] } + isBusy(model) ? { model } : transition(model, Model.Checking(), [CheckForUpdate()]) /** Install the update `check` found; the install stream starts once the state says Downloading. */ export const install = (model: Model): Return => - model._tag === "Available" - ? { model: Model.Downloading({ downloadedBytes: 0, totalBytes: null }) } - : { model } + model._tag === "Available" ? transition(model, Model.Downloading({ downloadedBytes: 0, totalBytes: null })) : { model } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { TickedUpdateCheck: () => check(model), - FoundUpdate: ({ version, body }) => ({ model: Model.Available({ version, body }) }), - FoundNoUpdate: ({ checkedAtMs }) => ({ model: Model.UpToDate({ checkedAtMs }) }), - FailedUpdateCheck: ({ message }) => ({ model: Model.CheckFailed({ message }) }), - ProgressedDownload: ({ downloadedBytes, totalBytes }) => ({ - model: Model.Downloading({ downloadedBytes, totalBytes }), - }), - StartedInstall: () => ({ model: Model.Installing() }), - StartedRelaunch: () => ({ model: Model.Restarting() }), - FailedInstall: ({ message }) => ({ model: Model.InstallFailed({ message }) }), + FoundUpdate: ({ version, body }) => transition(model, Model.Available({ version, body })), + FoundNoUpdate: ({ checkedAtMs }) => transition(model, Model.UpToDate({ checkedAtMs })), + FailedUpdateCheck: () => transition(model, Model.CheckFailed()), + ProgressedDownload: ({ downloadedBytes, totalBytes }) => + transition(model, Model.Downloading({ downloadedBytes, totalBytes })), + StartedInstall: () => transition(model, Model.Installing()), + StartedRelaunch: () => transition(model, Model.Restarting()), + FailedInstall: ({ reason }) => transition(model, Model.InstallFailed({ reason })), }) -/** Download with progress, install, then relaunch (or quit, if relaunch fails). */ +// SUBSCRIPTION + +/** + * Download with progress, install, then relaunch (or quit, if relaunch fails). The update handle is + * a live Tauri resource, so it is looked up again here instead of being kept between check and install. + */ const installStream: Stream.Stream<Message> = Stream.callback<Message>((queue) => Effect.gen(function* () { - const update = pendingUpdate - if (update === undefined) { - Queue.offerUnsafe(queue, Message.FailedInstall({ message: "No update to install" })) - return - } + const offer = (message: Message) => Effect.sync(() => Queue.offerUnsafe(queue, message)) + const fail = (reason: InstallFailure) => offer(Message.FailedInstall({ reason })) + const updater = getTauriUpdater() + const found = updater === undefined ? null : yield* updaterCall(() => updater.check()).pipe(Effect.orElseSucceed(() => null)) + if (found === null) return yield* fail("NoUpdate") + let downloadedBytes = 0 let totalBytes: number | null = null - const fail = (message: string) => - Effect.sync(() => Queue.offerUnsafe(queue, Message.FailedInstall({ message }))) - - const downloaded = yield* Effect.tryPromise(() => - update.download((event) => { + const downloaded = yield* updaterCall(() => + found.download((event) => { if (event.event === "Started") totalBytes = event.data.contentLength ?? null if (event.event === "Progress") downloadedBytes += event.data.chunkLength Queue.offerUnsafe(queue, Message.ProgressedDownload({ downloadedBytes, totalBytes })) }), ).pipe(Effect.result) - if (downloaded._tag === "Failure") - return yield* fail(messageOf(downloaded.failure.cause, "Download failed")) + if (downloaded._tag === "Failure") return yield* fail("DownloadFailed") - Queue.offerUnsafe(queue, Message.StartedInstall()) - const installed = yield* Effect.tryPromise(() => update.install()).pipe(Effect.result) - if (installed._tag === "Failure") - return yield* fail(messageOf(installed.failure.cause, "Installation failed")) + yield* offer(Message.StartedInstall()) + const installed = yield* updaterCall(() => found.install()).pipe(Effect.result) + if (installed._tag === "Failure") return yield* fail("InstallFailed") - Queue.offerUnsafe(queue, Message.StartedRelaunch()) + yield* offer(Message.StartedRelaunch()) // Give the installer a moment to finish before restarting. yield* Effect.sleep(Duration.millis(500)) const process = getTauriProcess() - if (process === undefined) - return yield* fail( - "Update installed, but restart is unavailable. Please quit and reopen Hazel manually.", - ) - const relaunched = yield* Effect.tryPromise(() => process.relaunch()).pipe(Effect.result) + if (process === undefined) return yield* fail("RestartUnavailable") + const relaunched = yield* updaterCall(() => process.relaunch()).pipe(Effect.result) if (relaunched._tag === "Success") return - const exited = yield* Effect.tryPromise(() => process.exit(0)).pipe(Effect.result) - if (exited._tag === "Failure") - yield* fail( - "Update installed, but restart failed and Hazel could not quit automatically. Please quit and reopen Hazel manually.", - ) + const exited = yield* updaterCall(() => process.exit(0)).pipe(Effect.result) + if (exited._tag === "Failure") yield* fail("RestartFailed") }).pipe(Effect.andThen(Effect.never)), ) diff --git a/apps/web-foldkit/src/redirect.ts b/apps/web-foldkit/src/redirect.ts index 673b600c7..1dd0531e4 100644 --- a/apps/web-foldkit/src/redirect.ts +++ b/apps/web-foldkit/src/redirect.ts @@ -1,6 +1,6 @@ import type { OrganizationId } from "@hazel/schema" import { Match, Option } from "effect" -import { AppRoute, hrefOf, isPublicRoute, organizationHref, signInHref } from "./route" +import { AppRoute, isPublicRoute, organizationRoute } from "./route" import type { Auth, CurrentUser } from "./session" /** The redirects legacy does in `beforeLoad` and `<Navigate>`, as pure functions of the route. */ @@ -9,23 +9,20 @@ import type { Auth, CurrentUser } from "./session" * Index routes that only forward somewhere else, whatever the session. Redirect-only routes with * a page (`ChannelSettings`) forward from the page's `init` instead. */ -export const routeRedirect = ( - route: AppRoute, - options: { readonly isProd: boolean }, -): Option.Option<string> => +export const routeRedirect = (route: AppRoute, options: { readonly isProd: boolean }): Option.Option<AppRoute> => Match.value(route).pipe( // `settings/debug.tsx` is dev-only. Match.tag("SettingsDebug", ({ orgSlug }) => - options.isProd - ? Option.some(hrefOf(AppRoute.SettingsGeneral({ orgSlug }))) - : Option.none<string>(), + options.isProd ? Option.some(AppRoute.SettingsGeneral({ orgSlug })) : Option.none<AppRoute>(), ), - Match.orElse(() => Option.none<string>()), + Match.orElse(() => Option.none<AppRoute>()), ) /** `_app/layout.tsx` `Gate`: a signed-out visitor on an app route goes to sign-in, then back. */ -export const authRedirect = (route: AppRoute, auth: Auth, currentUrl: string): Option.Option<string> => - auth === "SignedOut" && !isPublicRoute(route) ? Option.some(signInHref(currentUrl)) : Option.none() +export const authRedirect = (route: AppRoute, auth: Auth, currentUrl: string): Option.Option<AppRoute> => + auth === "SignedOut" && !isPublicRoute(route) + ? Option.some(AppRoute.SignIn({ redirectUrl: Option.some(currentUrl) })) + : Option.none() export interface RootMembership { readonly organizationId: OrganizationId @@ -33,16 +30,14 @@ export interface RootMembership { } /** `_app/index.tsx`: where `/` sends a signed-in user once their membership is known. */ -export const rootRedirect = (user: CurrentUser, membership: Option.Option<RootMembership>): string => { +export const rootRedirect = (user: CurrentUser, membership: Option.Option<RootMembership>): AppRoute => { if (!user.isOnboarded) - return hrefOf( - AppRoute.Onboarding({ - orgId: Option.map(membership, ({ organizationId }) => organizationId), - step: Option.none(), - }), - ) + return AppRoute.Onboarding({ + orgId: Option.map(membership, ({ organizationId }) => organizationId), + step: Option.none(), + }) return Option.match(membership, { - onNone: () => hrefOf(AppRoute.SelectOrganization()), - onSome: ({ organizationId, slug }) => organizationHref({ id: organizationId, slug }), + onNone: () => AppRoute.SelectOrganization(), + onSome: ({ organizationId, slug }) => organizationRoute({ id: organizationId, slug }), }) } diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 633890932..6bde89f9e 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -82,11 +82,12 @@ describe("urlToAppRoute", () => { it("decodes branded and query segments", () => { expect(parse(`/hazel/chat/${channelId}`)).toMatchObject({ orgSlug: "hazel", channelId }) expect(parse(`/hazel/profile/${userId}`)).toMatchObject({ userId }) - expect(parse("/sign-in/factor-one?redirect_url=%2Fhazel")).toMatchObject({ + expect(parse("/sign-in?redirect_url=%2Fhazel")).toMatchObject({ _tag: "SignIn", - splat: "factor-one", redirectUrl: Option.some("/hazel"), }) + // The old Clerk sub-routes (`/sign-in/factor-one`) are gone. + expect(parse("/sign-in/factor-one")._tag).toBe("NotFound") expect(parse(`/onboarding?orgId=${orgId}`)).toMatchObject({ orgId: Option.some(orgId), step: Option.none(), @@ -148,14 +149,14 @@ describe("redirects", () => { }) it("hides the debug settings in production", () => { - expect(routeRedirect(parse("/hazel/settings/debug"), { isProd: true })).toEqual( + expect(Option.map(routeRedirect(parse("/hazel/settings/debug"), { isProd: true }), hrefOf)).toEqual( Option.some("/hazel/settings"), ) expect(routeRedirect(parse("/hazel/settings/debug"), { isProd: false })).toEqual(Option.none()) }) it("sends signed-out visitors on app routes to sign-in", () => { - expect(authRedirect(parse("/"), "SignedOut", "/")).toEqual(Option.some("/sign-in?redirect_url=%2F")) + expect(Option.map(authRedirect(parse("/"), "SignedOut", "/"), hrefOf)).toEqual(Option.some("/sign-in?redirect_url=%2F")) expect(authRedirect(parse("/hazel/chat"), "SignedIn", "/hazel/chat")).toEqual(Option.none()) expect(authRedirect(parse("/hazel/chat"), "Loading", "/hazel/chat")).toEqual(Option.none()) expect(authRedirect(parse("/join/hazel"), "SignedOut", "/join/hazel")).toEqual(Option.none()) @@ -175,11 +176,11 @@ describe("redirects", () => { it("resolves the root redirect like `_app/index.tsx`", () => { const organizationId = Schema.decodeUnknownSync(OrganizationId)(orgId) const member = Option.some({ organizationId, slug: "hazel" }) - expect(rootRedirect(user(true), member)).toBe("/hazel") - expect(rootRedirect(user(true), Option.none())).toBe("/select-organization") - expect(rootRedirect(user(false), Option.none())).toBe("/onboarding") - expect(rootRedirect(user(false), member)).toBe(`/onboarding?orgId=${orgId}`) - expect(rootRedirect(user(true), Option.some({ organizationId, slug: null }))).toBe( + expect(hrefOf(rootRedirect(user(true), member))).toBe("/hazel") + expect(hrefOf(rootRedirect(user(true), Option.none()))).toBe("/select-organization") + expect(hrefOf(rootRedirect(user(false), Option.none()))).toBe("/onboarding") + expect(hrefOf(rootRedirect(user(false), member))).toBe(`/onboarding?orgId=${orgId}`) + expect(hrefOf(rootRedirect(user(true), Option.some({ organizationId, slug: null })))).toBe( `/onboarding/setup-organization?orgId=${orgId}`, ) }) @@ -197,20 +198,10 @@ describe("hrefOf", () => { const examples: { readonly [Tag in RouteTag]: ReadonlyArray<readonly [AppRoute, string]> } = { Root: [[AppRoute.Root(), "/"]], SignIn: [ - [AppRoute.SignIn({ splat: "", redirectUrl: none }), "/sign-in"], - [ - AppRoute.SignIn({ splat: "", redirectUrl: Option.some("/hazel/chat") }), - "/sign-in?redirect_url=%2Fhazel%2Fchat", - ], - [ - AppRoute.SignIn({ splat: "factor-one/sso", redirectUrl: Option.some("/hazel") }), - "/sign-in/factor-one/sso?redirect_url=%2Fhazel", - ], - ], - SignUp: [ - [AppRoute.SignUp({ splat: "", redirectUrl: none }), "/sign-up"], - [AppRoute.SignUp({ splat: "verify", redirectUrl: none }), "/sign-up/verify"], + [AppRoute.SignIn({ redirectUrl: none }), "/sign-in"], + [AppRoute.SignIn({ redirectUrl: Option.some("/hazel/chat") }), "/sign-in?redirect_url=%2Fhazel%2Fchat"], ], + SignUp: [[AppRoute.SignUp({ redirectUrl: none }), "/sign-up"]], AuthRegister: [ [AppRoute.AuthRegister({ flowId: none, reference: none }), "/auth/register"], [ diff --git a/apps/web-foldkit/src/route.ts b/apps/web-foldkit/src/route.ts index 01577dec6..ae0cdc107 100644 --- a/apps/web-foldkit/src/route.ts +++ b/apps/web-foldkit/src/route.ts @@ -1,13 +1,14 @@ import { ChannelId, OrganizationId, SyncConnectionId, UserId } from "@hazel/schema" -import { Option, Schema, pipe } from "effect" +import { Match, Option, Schema, pipe } from "effect" import { Route } from "foldkit" -import { defineRouteUnion, literal, query, restString, schemaSegment, slash, string } from "foldkit/route" +import * as Url from "foldkit/url" +import { defineRouteUnion, literal, query, schemaSegment, slash, string } from "foldkit/route" /** Flat route union mirroring the 39 legacy TanStack routes (`apps/web/src/routes`), plus NotFound. */ export const AppRoute = defineRouteUnion({ Root: {}, - SignIn: { splat: Schema.String, redirectUrl: Schema.Option(Schema.String) }, - SignUp: { splat: Schema.String, redirectUrl: Schema.Option(Schema.String) }, + SignIn: { redirectUrl: Schema.Option(Schema.String) }, + SignUp: { redirectUrl: Schema.Option(Schema.String) }, /** Where the OAuth callback sends a first-time user to pick their name (`?flowId=&reference=`). */ AuthRegister: { flowId: Schema.Option(Schema.String), reference: Schema.Option(Schema.String) }, /** Where the OAuth callback sends a user whose provider account it just linked (`?provider=`). */ @@ -99,27 +100,6 @@ const IntegrationCallbackQuery = Schema.Struct({ errorCode: Schema.OptionFromOptional(Schema.String), }).pipe(Schema.encodeKeys({ connectionStatus: "connection_status", errorCode: "error_code" })) -/** `/sign-in/$`: the bare prefix and any splat below it (old Clerk sub-route links). */ -const authRouters = (segment: "sign-in" | "sign-up", make: typeof AppRoute.SignIn | typeof AppRoute.SignUp) => - ({ - bare: pipe( - literal(segment), - query(RedirectQuery), - Route.mapTo({ - make: ({ redirectUrl }: { redirectUrl: Option.Option<string> }) => - make.make({ splat: "", redirectUrl }), - }), - ), - splat: pipe( - literal(segment), - slash(restString("splat")), - query(RedirectQuery), - Route.mapTo({ - make: (fields: { splat: string; redirectUrl: Option.Option<string> }) => make.make(fields), - }), - ), - }) as const - const org = string("orgSlug") const orgPath = (...segments: ReadonlyArray<string>) => segments.reduce((parser, segment) => pipe(parser, slash(literal(segment))), org) @@ -131,12 +111,11 @@ const channelSettings = (...segments: ReadonlyArray<string>) => pipe(channel("channels"), slash(literal("settings"))), ) -const signIn = authRouters("sign-in", AppRoute.SignIn) -const signUp = authRouters("sign-up", AppRoute.SignUp) - /** One router per route tag, used to parse URLs and to build them back (`hrefOf`). */ const routers = { Root: pipe(Route.root, Route.mapTo(AppRoute.Root)), + SignIn: pipe(literal("sign-in"), query(RedirectQuery), Route.mapTo(AppRoute.SignIn)), + SignUp: pipe(literal("sign-up"), query(RedirectQuery), Route.mapTo(AppRoute.SignUp)), AuthRegister: pipe( literal("auth"), slash(literal("register")), @@ -251,10 +230,8 @@ const routers = { export const urlToAppRoute = Route.parseUrlWithFallback( Route.oneOf( routers.Root, - signIn.bare, - signIn.splat, - signUp.bare, - signUp.splat, + routers.SignIn, + routers.SignUp, routers.AuthRegister, routers.AuthContinue, routers.AuthDesktop, @@ -304,11 +281,19 @@ export const urlToAppRoute = Route.parseUrlWithFallback( export const hrefOf = (route: AppRoute): string => AppRoute.match<string>(route, { ...routers, - SignIn: (signInRoute) => (signInRoute.splat === "" ? signIn.bare : signIn.splat)(signInRoute), - SignUp: (signUpRoute) => (signUpRoute.splat === "" ? signUp.bare : signUp.splat)(signUpRoute), NotFound: ({ path }) => path, }) +/** + * The route of a same-origin path (`/x?y`), e.g. a remembered page to return to. Query parameters + * the route does not model are dropped. + */ +export const routeOfPath = (path: string): AppRoute => + Option.match(Url.fromString(`https://hazel.invalid${path.startsWith("/") ? path : `/${path}`}`), { + onNone: () => AppRoute.NotFound({ path }), + onSome: urlToAppRoute, + }) + // HELPERS export const orgSlugOf = (route: AppRoute): string | undefined => @@ -326,61 +311,165 @@ export const isPublicRoute = AppRoute.isAnyOf([ "NotFound", ]) +/** `/$orgSlug/settings/*`. */ +export const isSettingsRoute = AppRoute.isAnyOf([ + "SettingsGeneral", + "TeamSettings", + "SettingsInvitations", + "SettingsCustomEmojis", + "SettingsDebug", + "SettingsConnectInvites", + "SettingsChatSync", + "SettingsChatSyncConnection", + "SettingsIntegrations", + "SettingsIntegrationsInstalled", + "SettingsIntegrationsMarketplace", + "SettingsIntegrationsYourApps", + "SettingsIntegration", +]) + +/** `/$orgSlug/my-settings/*`. */ +export const isMySettingsRoute = AppRoute.isAnyOf([ + "MySettingsAppearance", + "MySettingsProfile", + "MySettingsLinkedAccounts", + "MySettingsNotifications", + "MySettingsDesktop", +]) + +/** `/$orgSlug/notifications/*`. */ +export const isNotificationsRoute = AppRoute.isAnyOf([ + "NotificationsAll", + "NotificationsGeneral", + "NotificationsThreads", + "NotificationsDms", +]) + +/** `/$orgSlug/chat/$channelId/*`: a channel's messages, files or media. */ +export const isChannelChatRoute = AppRoute.isAnyOf(["ChatChannel", "ChatFiles", "ChatFilesMedia"]) + +/** `/$orgSlug/chat` and everything below it. */ +export const isChatRoute = AppRoute.isAnyOf(["ChatIndex", "ChatChannel", "ChatFiles", "ChatFilesMedia"]) + +/** The integration and chat sync pages, which sit in a narrower content column. */ +export const hasContentColumn = AppRoute.isAnyOf([ + "SettingsIntegrations", + "SettingsIntegrationsInstalled", + "SettingsIntegrationsMarketplace", + "SettingsIntegrationsYourApps", + "SettingsIntegration", + "SettingsChatSync", + "SettingsChatSyncConnection", +]) + /** Which secondary sidebar and section layout an org route gets (legacy `AppSidebar`). */ export type OrgSection = "Chat" | "Settings" | "MySettings" | "Notifications" export const orgSectionOf = (route: AppRoute): OrgSection | undefined => { if (orgSlugOf(route) === undefined) return undefined - if (route._tag.startsWith("Settings") || route._tag === "TeamSettings") return "Settings" - if (route._tag.startsWith("MySettings")) return "MySettings" - if (route._tag.startsWith("Notifications")) return "Notifications" + if (isSettingsRoute(route)) return "Settings" + if (isMySettingsRoute(route)) return "MySettings" + if (isNotificationsRoute(route)) return "Notifications" return "Chat" } +/** `currentChannelIdAtom`: the channel a chat route shows. */ +export const chatChannelIdOf = (route: AppRoute): ChannelId | null => + isChannelChatRoute(route) ? route.channelId : null + +/** The channel settings layout's tab a route shows (`channels/$channelId/settings/*`). */ +export const channelSettingsTabOf = (route: AppRoute): "overview" | "integrations" | "connect" | undefined => + AppRoute.matchOrElse<"overview" | "integrations" | "connect" | undefined>( + route, + { + ChannelSettingsOverview: () => "overview", + ChannelSettingsIntegrations: () => "integrations", + ChannelSettingsConnect: () => "connect", + }, + () => undefined, + ) + +/** The document title for a route (the tab and window title). */ +export const titleOf = (route: AppRoute): string => { + const section = AppRoute.matchOrElse<string | undefined>( + route, + { + SignIn: () => "Sign in", + SignUp: () => "Sign up", + Join: () => "Join", + Invite: () => "Invitation", + Onboarding: () => "Welcome", + OnboardingSetupOrganization: () => "Welcome", + SelectOrganization: () => "Select a workspace", + OrgHome: () => "Home", + NotFound: () => "Not found", + }, + () => + Match.value(orgSectionOf(route)).pipe( + Match.when("Chat", () => (isChatRoute(route) ? "Chat" : "Channel settings")), + Match.when("Settings", () => "Settings"), + Match.when("MySettings", () => "My settings"), + Match.when("Notifications", () => "Activity"), + Match.orElse(() => undefined), + ), + ) + return section === undefined ? "Hazel Chat" : `${section} · Hazel Chat` +} + /** `/onboarding` with its search in legacy order: `navigate({ search: (prev) => ({ ...prev, step }) })`. */ export const onboardingHref = (orgId: OrganizationId | null, step: string): string => hrefOf(AppRoute.Onboarding({ orgId: Option.fromNullishOr(orgId), step: Option.some(step) })) /** `/sign-in?redirect_url=`: where a signed-out visitor goes, coming back to `returnTo` afterwards. */ export const signInHref = (returnTo: string): string => - hrefOf(AppRoute.SignIn({ splat: "", redirectUrl: Option.some(returnTo) })) + hrefOf(AppRoute.SignIn({ redirectUrl: Option.some(returnTo) })) /** An organization's home, or its slug setup step while it has no slug yet. */ +export const organizationRoute = (organization: { + readonly id: OrganizationId + readonly slug: string | null +}): AppRoute => + organization.slug + ? AppRoute.OrgHome({ orgSlug: organization.slug }) + : AppRoute.OnboardingSetupOrganization({ orgId: Option.some(organization.id) }) + export const organizationHref = (organization: { readonly id: OrganizationId readonly slug: string | null -}): string => - hrefOf( - organization.slug - ? AppRoute.OrgHome({ orgSlug: organization.slug }) - : AppRoute.OnboardingSetupOrganization({ orgId: Option.some(organization.id) }), - ) +}): string => hrefOf(organizationRoute(organization)) /** A channel scrolled to one message (`?messageId=`, read by the channel page, not the route). */ export const chatMessageHref = (orgSlug: string, channelId: ChannelId, messageId: string): string => `${hrefOf(AppRoute.ChatChannel({ orgSlug, channelId }))}?${new URLSearchParams({ messageId })}` /** My Settings > Linked Accounts without an OAuth callback result. */ -export const linkedAccountsHref = (orgSlug: string): string => - hrefOf( - AppRoute.MySettingsLinkedAccounts({ - orgSlug, - connectionStatus: Option.none(), - provider: Option.none(), - errorCode: Option.none(), - }), - ) +export const linkedAccountsRoute = (orgSlug: string): AppRoute => + AppRoute.MySettingsLinkedAccounts({ + orgSlug, + connectionStatus: Option.none(), + provider: Option.none(), + errorCode: Option.none(), + }) + +export const linkedAccountsHref = (orgSlug: string): string => hrefOf(linkedAccountsRoute(orgSlug)) /** An integration's settings page without an OAuth callback result. */ +export const integrationRoute = (orgSlug: string, integrationId: string): AppRoute => + AppRoute.SettingsIntegration({ + orgSlug, + integrationId, + connectionStatus: Option.none(), + errorCode: Option.none(), + }) + export const integrationHref = (orgSlug: string, integrationId: string): string => - hrefOf( - AppRoute.SettingsIntegration({ - orgSlug, - integrationId, - connectionStatus: Option.none(), - errorCode: Option.none(), - }), - ) + hrefOf(integrationRoute(orgSlug, integrationId)) + +/** An org route, or `undefined` outside an org, where the org-only overlays never open. */ +export const orgRouteOf = ( + orgSlug: string | null, + toRoute: (orgSlug: string) => AppRoute, +): AppRoute | undefined => (orgSlug === null ? undefined : toRoute(orgSlug)) /** An org route's href, or `null` outside an org, where the org-only overlays never open. */ export const orgHrefOf = (orgSlug: string | null, toRoute: (orgSlug: string) => AppRoute): string | null => diff --git a/apps/web-foldkit/src/rpc.ts b/apps/web-foldkit/src/rpc.ts index 391019160..4709477b4 100644 --- a/apps/web-foldkit/src/rpc.ts +++ b/apps/web-foldkit/src/rpc.ts @@ -30,6 +30,7 @@ import { import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-middleware" import { CustomFetchLive } from "~/lib/services/common/api-client" import { HazelAuthClient, HazelAuthClientLive } from "./auth/client" +import { NotificationSinks } from "./app/notification-sinks" import { MessageWindows, MessageWindowsLive } from "./data/message-windows" /** Same groups, transport and auth middleware as the legacy `HazelRpcClient`, as a plain Effect service. */ @@ -83,11 +84,12 @@ export const HazelApiClientLive = Layer.effect(HazelApiClient, makeApiClient).pi ) /** The app's `resources`: what every Command and Subscription may use. */ -export type Resources = HazelRpc | HazelApiClient | MessageWindows | HazelAuthClient +export type Resources = HazelRpc | HazelApiClient | MessageWindows | HazelAuthClient | NotificationSinks export const ResourcesLive = Layer.mergeAll( HazelRpcLive, HazelApiClientLive, MessageWindowsLive, HazelAuthClientLive, + NotificationSinks.layer, ) diff --git a/apps/web-foldkit/src/shell/app-shell.ts b/apps/web-foldkit/src/shell/app-shell.ts index eb1d86806..034a5642a 100644 --- a/apps/web-foldkit/src/shell/app-shell.ts +++ b/apps/web-foldkit/src/shell/app-shell.ts @@ -1,6 +1,6 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { IconBell, IconDashboard, IconGear, IconMsgs, Logo } from "../icons" -import { AppRoute, hrefOf } from "../route" +import { AppRoute, hrefOf, isChatRoute, isNotificationsRoute, isSettingsRoute } from "../route" import { linkClassName } from "../ui/link" import { sidebarContent, @@ -16,7 +16,7 @@ import { sidebarStatic, } from "../ui/sidebar" import type * as Modal from "../ui/modal" -import { isActiveFuzzy, type ShellContext } from "./context" +import type { ShellContext } from "./context" import { mobileNav, mobileSidebarPlaceholder, mobileSidebarSheet } from "./mobile" import { hasDesktopTitlebar } from "../platform/desktop" @@ -51,7 +51,7 @@ const bellIcon = <Message>(h: HtmlBuilder<Message>, unreadCount: number): Html = /** `NavSidebar` */ const navRail = <Message>(h: HtmlBuilder<Message>, context: ShellContext, unreadCount: number): Html => { - const { orgSlug } = context + const { orgSlug, route } = context const home = hrefOf(AppRoute.OrgHome({ orgSlug })) const chat = hrefOf(AppRoute.ChatIndex({ orgSlug })) const notifications = hrefOf(AppRoute.NotificationsAll({ orgSlug })) @@ -79,25 +79,25 @@ const navRail = <Message>(h: HtmlBuilder<Message>, context: ShellContext, unread navItem( "Home", home, - context.pathname === home, + route._tag === "OrgHome", IconDashboard(h, { className: "size-5" }), ), navItem( "Chat", chat, - isActiveFuzzy(context.pathname, chat), + isChatRoute(route), IconMsgs(h, { className: "size-5" }), ), navItem( "Notifications", notifications, - isActiveFuzzy(context.pathname, notifications), + isNotificationsRoute(route), bellIcon(h, unreadCount), ), navItem( "Settings", settings, - isActiveFuzzy(context.pathname, settings), + isSettingsRoute(route), IconGear(h, { className: "size-5" }), ), ]), diff --git a/apps/web-foldkit/src/shell/channels-sidebar.ts b/apps/web-foldkit/src/shell/channels-sidebar.ts index 706b828c8..4e34c452b 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar.ts @@ -1,16 +1,17 @@ -import type { OrganizationId, UserId } from "@hazel/schema" +import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Effect } from "effect" -import { getEffectivePresenceStatus } from "~/utils/presence" import { Mount } from "foldkit" -import type { Update } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf, successToast } from "../data/actions" +import type { AppRoute } from "../route" import type * as Menu from "../ui/menu" import { sidebarContent, sidebarSectionGroup, sidebarStatic } from "../ui/sidebar" import type { ShellContext } from "./context" -import type { UserStatus } from "./model" -import { orgSwitcherHeader, type SwitcherOrg, userMenuFooter } from "./menus" -import { emptyData, Message, type Model } from "./channels-sidebar/model" +import type { SwitcherOrg, UserStatus } from "./model" +import { orgSwitcherHeader, userMenuFooter } from "./menus" +import { emptyData, Message, type Model, type SidebarOutcome } from "./channels-sidebar/model" import { PageOutMessage } from "../page/out-message" import { PersistDismissedHint } from "./channels-sidebar/hints" import { @@ -32,48 +33,38 @@ import { sectionGroupContent } from "./channels-sidebar/view" // MODEL AND MESSAGE -export { Message, Model } from "./channels-sidebar/model" +export { Message, Model, OpenMenu, SidebarFailure, SidebarOutcome } from "./channels-sidebar/model" // INIT -export const init = (): Model => ({ +/** `isCreateChannelHintDismissed` is read from storage at boot (`loadIsCreateChannelHintDismissed`). */ +export const init = (options: { readonly isCreateChannelHintDismissed: boolean }): Model => ({ organizationId: null, currentUserId: null, - nowMs: 0, ...emptyData, - isCreateChannelHintDismissed: false, + isCreateChannelHintDismissed: options.isCreateChannelHintDismissed, }) +export { loadIsCreateChannelHintDismissed } from "./channels-sidebar/hints" + // UPDATE export type { SidebarReturn } from "./channels-sidebar/menu-update" -/** - * Every DM partner's derived status at `nowMs`. After the first tick, a tick only lands (and - * re-renders) when a status flips, so the clock lags real time by at most one tick. - */ -const presenceStatuses = (model: Model, nowMs: number) => - model.presence - .map((presence) => - getEffectivePresenceStatus( - { - status: presence.status, - lastSeenAt: presence.lastSeenMs === null ? null : new Date(presence.lastSeenMs), - }, - nowMs, - ), - ) - .join() +const outcomeTitles: { readonly [O in SidebarOutcome]: string } = { + Muted: "Channel muted", + Unmuted: "Channel unmuted", + Favorited: "Added to favorites", + Unfavorited: "Removed from favorites", + Left: "Left channel successfully", + MovedToSection: "Channel moved to section", + MovedToDefault: "Channel moved to default", + SectionDeleted: "Section deleted", +} export const update = (model: Model, message: Message): SidebarReturn => Message.match<SidebarReturn>(message, { CompletedScrollActiveIntoView: () => ({ model }), - TickedPresenceClock: ({ nowMs }) => ({ - model: - model.nowMs !== 0 && presenceStatuses(model, model.nowMs) === presenceStatuses(model, nowMs) - ? model - : modifyFields(model, { nowMs: () => nowMs }), - }), UpdatedMembership: ({ membership }) => ({ model: modifyFields(model, { membership: () => membership }), }), @@ -104,14 +95,13 @@ export const update = (model: Model, message: Message): SidebarReturn => UpdatedDiscoverableChannels: ({ channels }) => ({ model: modifyFields(model, { discoverableChannels: () => channels }), }), - LoadedDismissedHints: ({ isCreateChannelHintDismissed }) => ({ - model: modifyFields(model, { isCreateChannelHintDismissed: () => isCreateChannelHintDismissed }), - }), ClickedDismissCreateChannelHint: () => ({ model: modifyFields(model, { isCreateChannelHintDismissed: () => true }), commands: [PersistDismissedHint({ hintId: "create-channel" })], }), CompletedPersistDismissedHint: () => ({ model }), + // Storage is full or blocked: the hint is already hidden for this session, it only comes back next boot. + FailedPersistDismissedHint: () => ({ model }), GotRowMenuMessage: ({ channelId, orgSlug, message }) => updateRowMenu(model, channelId, orgSlug, message), GotSectionMenuMessage: ({ sectionId: sectionKey, message }) => @@ -122,11 +112,35 @@ export const update = (model: Model, message: Message): SidebarReturn => outMessage: PageOutMessage.RequestedCommandPalette({ page: "home" }), }), ClickedSectionAction: ({ action }) => requestedSectionAction(model, action), - SucceededSidebarAction: ({ toast }) => ({ + SucceededSidebarAction: ({ outcome }) => ({ model, - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ toast: successToast(outcomeTitles[outcome]) }), + }), + FailedSidebarAction: ({ reason }) => ({ + model, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + MemberNotFound: { + title: "Membership not found", + description: "You may no longer be a member of this item.", + }, + ChannelNotFound: { + title: "Channel not found", + description: "This channel may have been deleted.", + }, + SectionNotFound: { + title: "Section not found", + description: "This section may have been deleted.", + }, + SectionAlreadyDeleted: { + title: "Section not found", + description: "This section may have already been deleted.", + }, + }), + ), + }), }), - FailedSidebarAction: ({ toast }) => ({ model, outMessage: PageOutMessage.RequestedToast({ toast }) }), }) /** The root learned the organization or the signed-in user; the sidebar's queries depend on both. */ @@ -179,14 +193,17 @@ export interface SidebarChrome<ParentMessage> { export interface ViewContext<ParentMessage> { readonly shell: ShellContext - readonly activeChannelId: string | undefined + /** `chatChannelIdOf(route)`: the channel whose chat or files are open, its row is active. */ + readonly activeChannelId: ChannelId | null + /** The root's wall clock (`presenceNowSignal`), for deriving stale DM presence. */ + readonly nowMs: number readonly chrome: SidebarChrome<ParentMessage> } -/** The shell fields the sidebar reads, as primitives so the lazy slot can compare them. */ +/** The shell fields the sidebar reads, as primitives or stable refs so the lazy slot can compare them. */ type ShellArgs = readonly [ orgSlug: string, - pathname: string, + route: AppRoute, orgName: string | undefined, orgLogoUrl: string | null | undefined, displayName: string | undefined, @@ -198,7 +215,8 @@ type ShellArgs = readonly [ const sidebarBody = <ParentMessage>( h: HtmlBuilder<ParentMessage>, model: Model, - activeChannelId: string | undefined, + activeChannelId: ChannelId | null, + nowMs: number, toParentMessage: (message: Message) => ParentMessage, userMenu: Menu.Model, orgSwitcher: Menu.Model, @@ -207,11 +225,11 @@ const sidebarBody = <ParentMessage>( statusExpiration: string | null, toUserMenuMessage: (message: Menu.Message) => ParentMessage, toOrgSwitcherMessage: (message: Menu.Message) => ParentMessage, - ...[orgSlug, pathname, orgName, orgLogoUrl, displayName, email, avatarUrl, appVersion]: ShellArgs + ...[orgSlug, route, orgName, orgLogoUrl, displayName, email, avatarUrl, appVersion]: ShellArgs ): Html => { const shell: ShellContext = { orgSlug, - pathname, + route, organization: orgName === undefined ? undefined : { name: orgName, logoUrl: orgLogoUrl ?? null }, currentUser: displayName === undefined || email === undefined @@ -226,8 +244,9 @@ const sidebarBody = <ParentMessage>( h, sectionGroupContent(h, model, { orgSlug, - pathname, + route, activeChannelId, + nowMs, onActiveMount: h.OnMount(Mount.mapMessage(ScrollActiveIntoView(), toParentMessage)), onBrowseChannels: h.OnClick(toParentMessage(Message.ClickedBrowseChannels())), onDismissCreateChannelHint: h.OnClick( @@ -278,6 +297,7 @@ export const view = <ParentMessage>( h, model, context.activeChannelId, + context.nowMs, toParentMessage, chrome.userMenu, chrome.orgSwitcher, @@ -287,7 +307,7 @@ export const view = <ParentMessage>( chrome.toUserMenuMessage, chrome.toOrgSwitcherMessage, shell.orgSlug, - shell.pathname, + shell.route, shell.organization?.name, shell.organization?.logoUrl, shell.currentUser?.displayName, diff --git a/apps/web-foldkit/src/shell/channels-sidebar/actions.ts b/apps/web-foldkit/src/shell/channels-sidebar/actions.ts index 021ab693c..57d5eae21 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/actions.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/actions.ts @@ -6,36 +6,38 @@ import { moveChannelToSectionAction, updateChannelMemberAction, } from "~/db/actions" -import { failureToast, settle, successToast } from "../../data/actions" +import { catchCommon } from "../../data/actions" import { HazelRpc } from "../../rpc" -import { Message } from "./model" +import { Message, type SidebarFailure, type SidebarOutcome } from "./model" -/** The sidebar menus' writes: the legacy optimistic actions and RPCs, with `exitToast` messages. */ +/** The sidebar menus' writes: optimistic actions and RPCs, settling into a typed outcome or reason. */ -const memberNotFound = { - ChannelMemberNotFoundError: { - title: "Membership not found", - description: "You may no longer be a member of this item.", - }, -} +const failed = (reason: SidebarFailure) => Message.FailedSidebarAction({ reason }) + +const MemberField = Schema.Literals(["isMuted", "isFavorite"]) -const succeeded = (title: string) => () => Message.SucceededSidebarAction({ toast: successToast(title) }) +const memberOutcomes: { + readonly [F in typeof MemberField.Type]: { readonly on: SidebarOutcome; readonly off: SidebarOutcome } +} = { + isMuted: { on: "Muted", off: "Unmuted" }, + isFavorite: { on: "Favorited", off: "Unfavorited" }, +} /** `useChannelMemberActions` mute and favorite toggles. */ export const UpdateChannelMember = Command.define("UpdateChannelMember", { - args: { - memberId: ChannelMemberId, - field: Schema.Literals(["isMuted", "isFavorite"]), - value: Schema.Boolean, - successTitle: Schema.String, - }, + args: { memberId: ChannelMemberId, field: MemberField, value: Schema.Boolean }, messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], - execute: ({ memberId, field, value, successTitle }) => - settle( - updateChannelMemberAction({ memberId, [field]: value }), - succeeded(successTitle), - (cause) => - Message.FailedSidebarAction({ toast: failureToast(cause, "friendly", memberNotFound) }), + execute: ({ memberId, field, value }) => + updateChannelMemberAction({ memberId, [field]: value }).pipe( + Effect.as( + Message.SucceededSidebarAction({ + outcome: value ? memberOutcomes[field].on : memberOutcomes[field].off, + }), + ), + Effect.catchTags({ + ChannelMemberNotFoundError: () => Effect.succeed(failed("MemberNotFound")), + }), + catchCommon(failed), ), }) @@ -44,14 +46,12 @@ export const LeaveChannel = Command.define("LeaveChannel", { args: { memberId: ChannelMemberId }, messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ memberId }) => - settle( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("channelMember.delete", { id: memberId }) + HazelRpc.use((rpc) => rpc("channelMember.delete", { id: memberId })).pipe( + Effect.as(Message.SucceededSidebarAction({ outcome: "Left" })), + Effect.catchTags({ + ChannelMemberNotFoundError: () => Effect.succeed(failed("MemberNotFound")), }), - succeeded("Left channel successfully"), - (cause) => - Message.FailedSidebarAction({ toast: failureToast(cause, "friendly", memberNotFound) }), + catchCommon(failed), ), }) @@ -59,22 +59,15 @@ export const MoveChannelToSection = Command.define("MoveChannelToSection", { args: { channelId: ChannelId, sectionId: Schema.NullOr(ChannelSectionId) }, messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ channelId, sectionId }) => - settle( - moveChannelToSectionAction({ channelId, sectionId }), - succeeded(sectionId ? "Channel moved to section" : "Channel moved to default"), - (cause) => - Message.FailedSidebarAction({ - toast: failureToast(cause, "friendly", { - ChannelNotFoundError: { - title: "Channel not found", - description: "This channel may have been deleted.", - }, - ChannelSectionNotFoundError: { - title: "Section not found", - description: "This section may have been deleted.", - }, - }), - }), + moveChannelToSectionAction({ channelId, sectionId }).pipe( + Effect.as( + Message.SucceededSidebarAction({ outcome: sectionId ? "MovedToSection" : "MovedToDefault" }), + ), + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(failed("ChannelNotFound")), + ChannelSectionNotFoundError: () => Effect.succeed(failed("SectionNotFound")), + }), + catchCommon(failed), ), }) @@ -82,14 +75,11 @@ export const DeleteChannelSection = Command.define("DeleteChannelSection", { args: { sectionId: ChannelSectionId }, messages: [Message.SucceededSidebarAction, Message.FailedSidebarAction], execute: ({ sectionId }) => - settle(deleteChannelSectionAction({ sectionId }), succeeded("Section deleted"), (cause) => - Message.FailedSidebarAction({ - toast: failureToast(cause, "friendly", { - ChannelSectionNotFoundError: { - title: "Section not found", - description: "This section may have already been deleted.", - }, - }), + deleteChannelSectionAction({ sectionId }).pipe( + Effect.as(Message.SucceededSidebarAction({ outcome: "SectionDeleted" })), + Effect.catchTags({ + ChannelSectionNotFoundError: () => Effect.succeed(failed("SectionAlreadyDeleted")), }), + catchCommon(failed), ), }) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/hints.ts b/apps/web-foldkit/src/shell/channels-sidebar/hints.ts index f4c310990..5434779e8 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/hints.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/hints.ts @@ -1,37 +1,33 @@ -import { Effect, Option, Schema, Stream } from "effect" +import { Effect, Option, Schema } from "effect" import { Command } from "foldkit" +import { readStored, writeStored } from "../../data/storage" import { Message } from "./model" /** * `useFeatureHint` (`atoms/feature-discovery-atoms.ts`): dismissed hints live in the same - * `hazel-dismissed-hints` localStorage entry, JSON-encoded like `Atom.kvs` writes it. + * `hazel-dismissed-hints` storage entry, JSON-encoded like `Atom.kvs` writes it. */ const STORAGE_KEY = "hazel-dismissed-hints" -const DismissedHints = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Boolean)) -const decodeHints = Schema.decodeUnknownOption(DismissedHints) -const encodeHints = Schema.encodeSync(DismissedHints) +const DismissedHints = Schema.toCodecJson(Schema.Record(Schema.String, Schema.Boolean)) -const readHints = Effect.sync( - (): Readonly<Record<string, boolean>> => - Option.getOrElse(decodeHints(globalThis.localStorage?.getItem(STORAGE_KEY) ?? "{}"), () => ({})), +/** Every dismissed hint; missing or undecodable storage reads as none dismissed. */ +const readHints = readStored(STORAGE_KEY, DismissedHints).pipe( + Effect.map(Option.getOrElse((): Readonly<Record<string, boolean>> => ({}))), ) -/** Emits whether the create-channel hint was dismissed, once, at startup. */ -export const dismissedHintStream = Stream.fromEffect(readHints).pipe( - Stream.map((hints) => - Message.LoadedDismissedHints({ isCreateChannelHintDismissed: hints["create-channel"] === true }), - ), +/** Whether the create-channel hint was dismissed; the root reads it once at boot, before the first render. */ +export const loadIsCreateChannelHintDismissed: Effect.Effect<boolean> = readHints.pipe( + Effect.map((hints) => hints["create-channel"] === true), ) export const PersistDismissedHint = Command.define("PersistDismissedHint", { args: { hintId: Schema.String }, - messages: [Message.CompletedPersistDismissedHint], + messages: [Message.CompletedPersistDismissedHint, Message.FailedPersistDismissedHint], execute: ({ hintId }) => - Effect.flatMap(readHints, (hints) => - Effect.sync(() => { - globalThis.localStorage?.setItem(STORAGE_KEY, encodeHints({ ...hints, [hintId]: true })) - return Message.CompletedPersistDismissedHint() - }), + readHints.pipe( + Effect.flatMap((hints) => writeStored(STORAGE_KEY, DismissedHints, { ...hints, [hintId]: true })), + Effect.as(Message.CompletedPersistDismissedHint()), + Effect.catchTag("StorageWriteError", () => Effect.succeed(Message.FailedPersistDismissedHint())), ), }) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/items.ts b/apps/web-foldkit/src/shell/channels-sidebar/items.ts index ccf18c7ba..c44bc5759 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/items.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/items.ts @@ -18,22 +18,16 @@ export const CHANNEL_ACTIVE = "bg-sidebar-accent font-medium text-sidebar-accent export interface ItemContext { readonly orgSlug: string - readonly pathname: string + /** The channel the route shows (its chat or its files), whose link is active. */ + readonly activeChannelId: ChannelId | null readonly currentUserId: string | null readonly nowMs: number readonly presenceByUser: ReadonlyMap<string, Presence> } -/** TanStack's default (non-exact) active match. */ -const isActiveFuzzy = (pathname: string, to: string) => pathname === to || pathname.startsWith(`${to}/`) - const chatHref = (context: { readonly orgSlug: string }, channelId: ChannelId) => hrefOf(AppRoute.ChatChannel({ orgSlug: context.orgSlug, channelId })) -/** Whether a channel's sidebar link is active at `pathname`, read before the row's memo. */ -export const isChannelLinkActive = (pathname: string, orgSlug: string, channelId: ChannelId) => - isActiveFuzzy(pathname, chatHref({ orgSlug }, channelId)) - /** What one memoized row reads instead of the whole `ItemContext`. */ export interface RowLinkContext { readonly orgSlug: string @@ -147,7 +141,7 @@ export const discoverableItem = <M>( h, { href, - isActive: isActiveFuzzy(context.pathname, href), + isActive: context.activeChannelId === channel.id, activeClassName: `active ${CHANNEL_ACTIVE}`, }, [channelIcon(h, channel.icon), label(h, [channel.name])], diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts b/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts index 589a11bd6..816c6667a 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menu-update.ts @@ -1,17 +1,26 @@ import { canPerform, RPC_SCOPE_MAP } from "@hazel/domain/scopes" import type { RpcActionName } from "@hazel/domain/scopes" import type { ChannelId } from "@hazel/schema" -import { Array, Option } from "effect" +import { Array, Match, Option } from "effect" import { Command, type Update } from "foldkit" import { modifyFields } from "foldkit/struct" import type { ModalRequest } from "../../overlay/modal" import { PageOutMessage } from "../../page/out-message" -import { AppRoute, hrefOf } from "../../route" +import { AppRoute } from "../../route" import type { HazelRpc } from "../../rpc" import * as Menu from "../../ui/menu" import { DeleteChannelSection, LeaveChannel, MoveChannelToSection, UpdateChannelMember } from "./actions" -import { closedMenu, closedRowMenu, sectionIdOfKey, sectionMenuEntries, sectionMenuId } from "./menus" -import { Message, type Model, type SectionAction } from "./model" +import { + closedMenu, + closedRowMenu, + decodeRowMenuKey, + decodeSectionMenuKey, + type RowMenuKey, + sectionIdOfKey, + sectionMenuEntries, + sectionMenuId, +} from "./menus" +import { Message, type Model, OpenMenu, type SectionAction } from "./model" import type { ChannelEntry } from "./rows" /** Opening, folding and acting on the row and section menus. */ @@ -53,26 +62,32 @@ const entryOf = (model: Model, channelId: ChannelId): Option.Option<ChannelEntry (entry) => entry.channel.id === channelId, ) -/** The open menu for `target`, or a closed one built from the current data. */ -export const menuFor = (model: Model, target: string, build: () => Menu.Model): Menu.Model => - model.openMenu !== null && model.openMenu.target === target ? model.openMenu.menu : build() +/** Whether `openMenu` is the row menu of `channelId`. */ +const isRowMenuOf = (channelId: ChannelId) => (openMenu: OpenMenu) => + openMenu._tag === "Row" && openMenu.channelId === channelId -export const rowMenuOf = (model: Model, channelId: ChannelId) => - menuFor(model, `channel:${channelId}`, () => - closedRowMenu(channelId, model.sections, canOn(model, "channel.delete")), - ) +/** Whether `openMenu` is the "+" menu of the section `sectionKey`. */ +const isSectionMenuOf = (sectionKey: string) => (openMenu: OpenMenu) => + openMenu._tag === "Section" && openMenu.sectionKey === sectionKey -export const sectionMenuOf = (model: Model, sectionKey: string) => - menuFor(model, `section:${sectionKey}`, () => { - const { actions, isEditable } = sectionActionsOf(model, sectionKey) - return closedMenu(sectionMenuId(sectionKey), sectionMenuEntries(actions, isEditable)) - }) +/** The open row menu of `channelId`, or `null` while it is closed. */ +export const openRowMenu = (openMenu: Model["openMenu"], channelId: ChannelId): Menu.Model | null => + openMenu !== null && isRowMenuOf(channelId)(openMenu) ? openMenu.menu : null + +export const rowMenuOf = (model: Model, channelId: ChannelId): Menu.Model => + openRowMenu(model.openMenu, channelId) ?? + closedRowMenu(channelId, model.sections, canOn(model, "channel.delete")) + +export const sectionMenuOf = (model: Model, sectionKey: string): Menu.Model => { + if (model.openMenu !== null && isSectionMenuOf(sectionKey)(model.openMenu)) return model.openMenu.menu + const { actions, isEditable } = sectionActionsOf(model, sectionKey) + return closedMenu(sectionMenuId(sectionKey), sectionMenuEntries(actions, isEditable)) +} /** Folds a Menu step; the menu is kept only while open. */ const foldMenu = ( model: Model, - target: string, - orgSlug: string, + target: Readonly<{ is: (openMenu: OpenMenu) => boolean; opened: (menu: Menu.Model) => OpenMenu }>, result: Update.ReturnWithOutMessage<Menu.Model, Menu.Message, Menu.OutMessage>, toMessage: (message: Menu.Message) => Message, onSelect: (model: Model, key: string) => SidebarReturn, @@ -80,11 +95,7 @@ const foldMenu = ( const isOpen = result.model.popup._tag === "Open" const next = modifyFields(model, { openMenu: (current) => - isOpen - ? { target, orgSlug, menu: result.model } - : current !== null && current.target === target - ? null - : current, + isOpen ? target.opened(result.model) : current !== null && target.is(current) ? null : current, }) const commands = Command.mapMessages(result.commands ?? [], toMessage) if (result.outMessage === undefined || result.outMessage._tag !== "SelectedItem") @@ -93,90 +104,84 @@ const foldMenu = ( return { ...selected, commands: [...commands, ...(selected.commands ?? [])] } } +const selectedRowKey = ( + model: Model, + { channel, member }: ChannelEntry, + orgSlug: string, + key: RowMenuKey, +): SidebarReturn => + Match.value(key).pipe( + Match.when("mute", () => ({ + model, + commands: [ + UpdateChannelMember({ memberId: member.id, field: "isMuted", value: !member.isMuted }), + ], + })), + Match.when("favorite", () => ({ + model, + commands: [ + UpdateChannelMember({ memberId: member.id, field: "isFavorite", value: !member.isFavorite }), + ], + })), + Match.when("settings", () => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + route: AppRoute.ChannelSettings({ orgSlug, channelId: channel.id }), + replace: false, + }), + })), + Match.when("delete", () => ({ + model, + outMessage: PageOutMessage.RequestedModal({ + modal: { _tag: "DeleteChannel", channelId: channel.id, channelName: channel.name }, + }), + })), + Match.when("leave", () => ({ model, commands: [LeaveChannel({ memberId: member.id })] })), + Match.exhaustive, + ) + +/** A submenu leaf: "Move to section"; the current section is a no-op, as in legacy. */ +const selectedMoveTarget = (model: Model, { channel }: ChannelEntry, key: string): SidebarReturn => + Option.match(sectionIdOfKey(key, model.sections), { + onNone: () => ({ model }), + onSome: (sectionId) => + sectionId === channel.sectionId + ? { model } + : { model, commands: [MoveChannelToSection({ channelId: channel.id, sectionId })] }, + }) + const selectedRowItem = (channelId: ChannelId, orgSlug: string) => (model: Model, key: string): SidebarReturn => Option.match(entryOf(model, channelId), { onNone: () => ({ model }), - onSome: ({ channel, member }) => { - if (key === "mute") - return { - model, - commands: [ - UpdateChannelMember({ - memberId: member.id, - field: "isMuted", - value: !member.isMuted, - successTitle: member.isMuted ? "Channel unmuted" : "Channel muted", - }), - ], - } - if (key === "favorite") - return { - model, - commands: [ - UpdateChannelMember({ - memberId: member.id, - field: "isFavorite", - value: !member.isFavorite, - successTitle: member.isFavorite - ? "Removed from favorites" - : "Added to favorites", - }), - ], - } - if (key === "settings") - return { - model, - outMessage: PageOutMessage.RequestedNavigation({ - href: hrefOf(AppRoute.ChannelSettings({ orgSlug, channelId: channel.id })), - replace: false, - }), - } - if (key === "delete") - return { - model, - outMessage: PageOutMessage.RequestedModal({ - modal: { - _tag: "DeleteChannel", - channelId: channel.id, - channelName: channel.name, - }, - }), - } - if (key === "leave") return { model, commands: [LeaveChannel({ memberId: member.id })] } - // A submenu leaf: "Move to section"; the current section is a no-op, as in legacy. - return Option.match(sectionIdOfKey(key, model.sections), { - onNone: () => ({ model }), - onSome: (sectionId) => - sectionId === channel.sectionId - ? { model } - : { - model, - commands: [MoveChannelToSection({ channelId: channel.id, sectionId })], - }, - }) - }, + onSome: (entry) => + Option.match(decodeRowMenuKey(key), { + onNone: () => selectedMoveTarget(model, entry, key), + onSome: (rowKey) => selectedRowKey(model, entry, orgSlug, rowKey), + }), }) +const deletedSection = (model: Model, sectionKey: string): SidebarReturn => + Option.match( + Array.findFirst(model.sections, (section) => section.id === sectionKey), + { + onNone: () => ({ model }), + onSome: (section) => ({ model, commands: [DeleteChannelSection({ sectionId: section.id })] }), + }, + ) + const selectedSectionItem = (sectionKey: string) => - (model: Model, key: string): SidebarReturn => { - if (key === "delete-section") - return Option.match( - Array.findFirst(model.sections, (section) => section.id === sectionKey), - { - onNone: () => ({ model }), - onSome: (section) => ({ - model, - commands: [DeleteChannelSection({ sectionId: section.id })], - }), - }, - ) - return key === "create-channel" || key === "join-channel" || key === "create-dm" - ? requestedSectionAction(model, key) - : { model } - } + (model: Model, key: string): SidebarReturn => + Option.match(decodeSectionMenuKey(key), { + onNone: () => ({ model }), + onSome: (sectionMenuKey) => + Match.value(sectionMenuKey).pipe( + Match.when("delete-section", () => deletedSection(model, sectionKey)), + Match.orElse((action) => requestedSectionAction(model, action)), + ), + }) export const updateRowMenu = ( model: Model, @@ -186,8 +191,7 @@ export const updateRowMenu = ( ): SidebarReturn => foldMenu( model, - `channel:${channelId}`, - orgSlug, + { is: isRowMenuOf(channelId), opened: (menu) => OpenMenu.Row({ channelId, orgSlug, menu }) }, Menu.update(rowMenuOf(model, channelId), message), (child) => Message.GotRowMenuMessage({ channelId, orgSlug, message: child }), selectedRowItem(channelId, orgSlug), @@ -196,8 +200,7 @@ export const updateRowMenu = ( export const updateSectionMenu = (model: Model, sectionKey: string, message: Menu.Message): SidebarReturn => foldMenu( model, - `section:${sectionKey}`, - "", + { is: isSectionMenuOf(sectionKey), opened: (menu) => OpenMenu.Section({ sectionKey, menu }) }, Menu.update(sectionMenuOf(model, sectionKey), message), (child) => Message.GotSectionMenuMessage({ sectionId: sectionKey, message: child }), selectedSectionItem(sectionKey), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts index 52b57bcc6..f4f1ad12f 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts @@ -1,5 +1,5 @@ import type { ChannelSectionId } from "@hazel/schema" -import { Array, Option } from "effect" +import { Array, Option, Schema } from "effect" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { IconCirclePlus, @@ -17,7 +17,7 @@ import { import { button } from "../../ui/button" import * as Menu from "../../ui/menu" import { menuLabel, view as menuView } from "../../ui/menu-view" -import type { SectionAction } from "./model" +import { SectionAction } from "./model" import type { ChannelEntry, Section } from "./rows" /** The channel row "dots" menu (`ChannelItem`) and the section "+" menu (`SectionGroup`). */ @@ -29,14 +29,28 @@ const DEFAULT_SECTION_KEY = "section:default" const sectionKeyOf = (sectionId: string | null) => sectionId === null ? DEFAULT_SECTION_KEY : `section:${sectionId}` +// KEYS + +/** The row menu's fixed items; "Move to section" leaves are keyed per section (`sectionIdOfKey`). */ +export const RowMenuKey = Schema.Literals(["mute", "favorite", "settings", "delete", "leave"]) +export type RowMenuKey = typeof RowMenuKey.Type +export const decodeRowMenuKey = Schema.decodeUnknownOption(RowMenuKey) + +/** The section menu's items: its actions plus "Delete section". */ +export const SectionMenuKey = Schema.Union([SectionAction, Schema.Literal("delete-section")]) +export type SectionMenuKey = typeof SectionMenuKey.Type +export const decodeSectionMenuKey = Schema.decodeUnknownOption(SectionMenuKey) + +const rowItem = (key: RowMenuKey, options?: Parameters<typeof Menu.item>[1]) => Menu.item(key, options) + // ENTRIES export const rowMenuEntries = ( sections: ReadonlyArray<Section>, canDelete: boolean, ): ReadonlyArray<Menu.Entry> => [ - Menu.item("mute"), - Menu.item("favorite"), + rowItem("mute"), + rowItem("favorite"), ...(sections.length > 0 ? [ Menu.item("move", { @@ -50,10 +64,10 @@ export const rowMenuEntries = ( ] : []), Menu.separator, - Menu.item("settings"), - ...(canDelete ? [Menu.item("delete", { intent: "Danger" })] : []), + rowItem("settings"), + ...(canDelete ? [rowItem("delete", { intent: "Danger" })] : []), Menu.separator, - Menu.item("leave", { intent: "Danger" }), + rowItem("leave", { intent: "Danger" }), ] /** The move target a submenu key names: `null` for the default section. */ @@ -73,8 +87,10 @@ export const sectionMenuEntries = ( actions: ReadonlyArray<SectionAction>, isEditable: boolean, ): ReadonlyArray<Menu.Entry> => [ - ...actions.map((action) => Menu.item(action)), - ...(isEditable ? [Menu.separator, Menu.item("delete-section", { intent: "Danger" })] : []), + ...actions.map((action) => Menu.item(action satisfies SectionMenuKey)), + ...(isEditable + ? [Menu.separator, Menu.item("delete-section" satisfies SectionMenuKey, { intent: "Danger" })] + : []), ] export const closedMenu = (id: string, entries: ReadonlyArray<Menu.Entry>, placement?: "right top") => diff --git a/apps/web-foldkit/src/shell/channels-sidebar/model.ts b/apps/web-foldkit/src/shell/channels-sidebar/model.ts index 1acbffe68..03ab6edec 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/model.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/model.ts @@ -1,7 +1,8 @@ import { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../overlay/toasts" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as Menu from "../../ui/menu" import { ChannelEntry, @@ -17,11 +18,17 @@ import { // MODEL +/** The one open menu: a channel row's dots menu or a section's "+" menu. Closed menus are not kept. */ +export const OpenMenu = defineTaggedUnion({ + // The row menu's items link into the org, so the slug the view passed along stays with it. + Row: { channelId: ChannelId, orgSlug: Schema.String, menu: Menu.Model }, + Section: { sectionKey: Schema.String, menu: Menu.Model }, +}) +export type OpenMenu = typeof OpenMenu.Type + export const Model = Schema.Struct({ organizationId: Schema.NullOr(OrganizationId), currentUserId: Schema.NullOr(UserId), - /** `presenceNowSignal`: wall clock for deriving stale presence. */ - nowMs: Schema.Number, membership: Schema.NullOr(Membership), sections: Schema.Array(Section), /** Keyed by section id, `"default"` for channels without a section. */ @@ -37,10 +44,7 @@ export const Model = Schema.Struct({ discoverableChannels: Schema.Array(DiscoverableChannel), /** `useFeatureHint("create-channel")`, persisted across organizations. */ isCreateChannelHintDismissed: Schema.Boolean, - /** The one open row or section menu (`channel:<id>` or `section:<key>`); closed menus are not kept. */ - openMenu: Schema.NullOr( - Schema.Struct({ target: Schema.String, orgSlug: Schema.String, menu: Menu.Model }), - ), + openMenu: Schema.NullOr(OpenMenu), }) export type Model = typeof Model.Type @@ -49,9 +53,28 @@ export type Model = typeof Model.Type export const SectionAction = Schema.Literals(["create-channel", "join-channel", "create-dm"]) export type SectionAction = typeof SectionAction.Type +/** What a sidebar menu write did; `update` turns it into the success toast. */ +export const SidebarOutcome = Schema.Literals([ + "Muted", + "Unmuted", + "Favorited", + "Unfavorited", + "Left", + "MovedToSection", + "MovedToDefault", + "SectionDeleted", +]) +export type SidebarOutcome = typeof SidebarOutcome.Type + +/** What can go wrong in a sidebar menu write, beyond the shared reasons. */ +export const SidebarFailure = Schema.Union([ + Schema.Literals(["MemberNotFound", "ChannelNotFound", "SectionNotFound", "SectionAlreadyDeleted"]), + CommonReason, +]) +export type SidebarFailure = typeof SidebarFailure.Type + export const Message = defineMessageUnion({ CompletedScrollActiveIntoView: {}, - TickedPresenceClock: { nowMs: Schema.Number }, UpdatedMembership: { membership: Schema.NullOr(Membership) }, UpdatedSections: { sections: Schema.Array(Section) }, UpdatedSectionChannels: { sectionKey: Schema.String, channels: Schema.Array(ChannelEntry) }, @@ -66,16 +89,16 @@ export const Message = defineMessageUnion({ UpdatedDiscoverableChannels: { channels: Schema.Array(DiscoverableChannel) }, /** "Browse channels"; the sidebar answers with `RequestedCommandPalette`. */ ClickedBrowseChannels: {}, - LoadedDismissedHints: { isCreateChannelHintDismissed: Schema.Boolean }, ClickedDismissCreateChannelHint: {}, CompletedPersistDismissedHint: {}, + FailedPersistDismissedHint: {}, // The row menu's items link into the org; update has no shell context, so the view passes the slug along. // oxlint-disable-next-line foldkit/got-wrapper-carries-only-routing GotRowMenuMessage: { channelId: ChannelId, orgSlug: Schema.String, message: Menu.Message }, GotSectionMenuMessage: { sectionId: Schema.String, message: Menu.Message }, ClickedSectionAction: { action: SectionAction }, - SucceededSidebarAction: { toast: ToastRequest }, - FailedSidebarAction: { toast: ToastRequest }, + SucceededSidebarAction: { outcome: SidebarOutcome }, + FailedSidebarAction: { reason: SidebarFailure }, }) export type Message = typeof Message.Type @@ -93,4 +116,4 @@ export const emptyData = { memberChannelIds: null, discoverableChannels: [], openMenu: null, -} satisfies Omit<Model, "organizationId" | "currentUserId" | "nowMs" | "isCreateChannelHintDismissed"> +} satisfies Omit<Model, "organizationId" | "currentUserId" | "isCreateChannelHintDismissed"> diff --git a/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts b/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts new file mode 100644 index 000000000..c3cfdcf05 --- /dev/null +++ b/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import type { ChannelId, ChannelMemberId, ChannelSectionId } from "@hazel/schema" +import { Effect } from "effect" +import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { errorToast, successToast } from "../../data/actions" +import { PageOutMessage } from "../../page/out-message" +import { AppRoute } from "../../route" +import * as Menu from "../../ui/menu" +import { + init, + loadIsCreateChannelHintDismissed, + Message, + type Model, + OpenMenu, + update, +} from "../channels-sidebar" +import { UpdateChannelMember } from "./actions" +import { PersistDismissedHint } from "./hints" + +/** The sidebar's menus, hint and action toasts: typed menu identity, outcomes and reasons. */ + +const channelId = "00000000-0000-4000-8000-000000000001" as ChannelId +const memberId = "00000000-0000-4000-8000-000000000002" as ChannelMemberId +const sectionId = "00000000-0000-4000-8000-000000000003" as ChannelSectionId + +const entry = { + channel: { id: channelId, name: "general", type: "public", icon: null, sectionId: null }, + member: { id: memberId, isMuted: false, isFavorite: false, notificationCount: 0 }, +} + +const loaded: Model = { + ...init({ isCreateChannelHintDismissed: false }), + sections: [{ id: sectionId, name: "Projects" }], + sectionChannels: { default: [entry] }, +} + +const rowMenu = (state: Model, menuMessage: Menu.Message) => + update(state, Message.GotRowMenuMessage({ channelId, orgSlug: "acme", message: menuMessage })) + +const sectionMenu = (state: Model, menuMessage: Menu.Message) => + update(state, Message.GotSectionMenuMessage({ sectionId, message: menuMessage })) + +const pressed = Menu.Message.PressedTrigger({ pointerType: "mouse" }) + +describe("open menu", () => { + test("opening a row menu keeps it as a Row, keyed by channel", () => { + const { model: next } = rowMenu(loaded, pressed) + expect(next.openMenu?._tag).toBe("Row") + expect(next.openMenu).toMatchObject({ _tag: "Row", channelId, orgSlug: "acme" }) + }) + + test("opening a section menu replaces the open row menu", () => { + const withRow = rowMenu(loaded, pressed).model + const { model: next } = sectionMenu(withRow, pressed) + expect(next.openMenu).toMatchObject({ _tag: "Section", sectionKey: sectionId }) + }) + + test("a section menu step never closes another target's open row menu", () => { + const withRow = rowMenu(loaded, pressed).model + const { model: next } = sectionMenu(withRow, Menu.Message.PressedOutside()) + expect(next.openMenu).toBe(withRow.openMenu) + }) + + test("selecting mute closes the menu and mutes the member", () => { + const open = rowMenu(loaded, pressed).model + const { model: next, commands } = rowMenu(open, Menu.Message.ClickedItem({ key: "mute" })) + expect(next.openMenu).toBeNull() + expect(commands?.find((command) => command.name === UpdateChannelMember.name)?.args).toEqual({ + memberId, + field: "isMuted", + value: true, + }) + }) + + test("settings navigates to the channel's settings route", () => { + const open = rowMenu(loaded, pressed).model + const { outMessage } = rowMenu(open, Menu.Message.ClickedItem({ key: "settings" })) + expect(outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + route: AppRoute.ChannelSettings({ orgSlug: "acme", channelId }), + replace: false, + }), + ) + }) + + test("a section-menu action asks for its modal", () => { + const open = sectionMenu(loaded, pressed).model + const { outMessage } = sectionMenu(open, Menu.Message.ClickedItem({ key: "join-channel" })) + expect(outMessage).toEqual(PageOutMessage.RequestedModal({ modal: { _tag: "JoinChannel" } })) + }) + + test("OpenMenu is a tagged union", () => { + const menu = Menu.init({ id: "x", entries: [] }) + expect(OpenMenu.Section({ sectionKey: "dms", menu })._tag).toBe("Section") + }) +}) + +describe("sidebar action toasts", () => { + test("each outcome keeps its legacy success copy", () => { + story( + update, + given(loaded), + message(Message.SucceededSidebarAction({ outcome: "Muted" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Channel muted") })), + message(Message.SucceededSidebarAction({ outcome: "Unfavorited" })), + expectOutMessage( + PageOutMessage.RequestedToast({ toast: successToast("Removed from favorites") }), + ), + message(Message.SucceededSidebarAction({ outcome: "MovedToDefault" })), + expectOutMessage( + PageOutMessage.RequestedToast({ toast: successToast("Channel moved to default") }), + ), + ) + }) + + test("feature reasons get their own copy, common reasons the shared copy", () => { + story( + update, + given(loaded), + message(Message.FailedSidebarAction({ reason: "SectionAlreadyDeleted" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Section not found", "This section may have already been deleted."), + }), + ), + message(Message.FailedSidebarAction({ reason: "MemberNotFound" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Membership not found", "You may no longer be a member of this item."), + }), + ), + message(Message.FailedSidebarAction({ reason: "Offline" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Connection lost", "Check your internet connection and try again."), + }), + ), + ) + }) +}) + +describe("create-channel hint", () => { + beforeEach(() => localStorage.clear()) + afterEach(() => vi.restoreAllMocks()) + + test("dismissing hides it and persists; a failed write is ignored", () => { + story( + update, + given(loaded), + message(Message.ClickedDismissCreateChannelHint()), + Command.expectExact(PersistDismissedHint({ hintId: "create-channel" })), + model((next) => expect(next.isCreateChannelHintDismissed).toBe(true)), + Command.resolve(PersistDismissedHint, Message.FailedPersistDismissedHint()), + expectNoOutMessage(), + model((next) => expect(next.isCreateChannelHintDismissed).toBe(true)), + ) + }) + + test("persisting merges into the stored hints, which load at boot", async () => { + localStorage.setItem("hazel-dismissed-hints", JSON.stringify({ other: true })) + expect(await Effect.runPromise(loadIsCreateChannelHintDismissed)).toBe(false) + const result = await Effect.runPromise(PersistDismissedHint({ hintId: "create-channel" }).effect) + expect(result).toEqual(Message.CompletedPersistDismissedHint()) + expect(JSON.parse(localStorage.getItem("hazel-dismissed-hints") ?? "")).toEqual({ + other: true, + "create-channel": true, + }) + expect(await Effect.runPromise(loadIsCreateChannelHintDismissed)).toBe(true) + }) + + test("a blocked storage write settles as FailedPersistDismissedHint, not a defect", async () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("quota", "QuotaExceededError") + }) + const result = await Effect.runPromise(PersistDismissedHint({ hintId: "create-channel" }).effect) + expect(result).toEqual(Message.FailedPersistDismissedHint()) + }) + + test("undecodable storage reads as not dismissed", async () => { + localStorage.setItem("hazel-dismissed-hints", "not json") + expect(await Effect.runPromise(loadIsCreateChannelHintDismissed)).toBe(false) + }) +}) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/subscriptions.ts b/apps/web-foldkit/src/shell/channels-sidebar/subscriptions.ts index 91c632617..ac41fb44c 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/subscriptions.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/subscriptions.ts @@ -2,7 +2,6 @@ import { ChannelId, ChannelSectionId, OrganizationId, OrganizationMemberId, User import { Schema, Stream } from "effect" import { Subscription } from "foldkit" import * as Data from "./data" -import { dismissedHintStream } from "./hints" import { dmPartners } from "./items" import { Message, type Model } from "./model" import * as People from "./people-data" @@ -41,17 +40,6 @@ const partnerUserIds = (model: Model): Array<UserId> => ].sort() export const subscriptions = Subscription.make<Model, Message>()((entry) => ({ - sidebarDismissedHints: Subscription.persistentEntry(dismissedHintStream), - sidebarPresenceClock: entry( - {}, - { - modelToDependencies: () => ({}), - dependenciesToStream: () => - Stream.concat(Stream.succeed(undefined), Stream.tick("5 seconds")).pipe( - Stream.map(() => Message.TickedPresenceClock({ nowMs: Date.now() })), - ), - }, - ), sidebarMembership: entry(Scope, { modelToDependencies: scopeOf, dependenciesToStream: scoped((organizationId, userId) => diff --git a/apps/web-foldkit/src/shell/channels-sidebar/view.ts b/apps/web-foldkit/src/shell/channels-sidebar/view.ts index 7eff5b04d..bd78653cb 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/view.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/view.ts @@ -5,7 +5,7 @@ import { IconMagnifier3, IconUsers } from "../../icons" import { AppRoute, hrefOf } from "../../route" import { sidebarItem, sidebarLink } from "../../ui/sidebar" import type * as Menu from "../../ui/menu" -import { canOn } from "./menu-update" +import { canOn, openRowMenu } from "./menu-update" import { closedRowMenu, rowMenuView } from "./menus" import { Message, type Model } from "./model" import { createChannelHint } from "./create-channel-hint" @@ -17,7 +17,6 @@ import { dmDisplayName, dmItem, dmPartners, - isChannelLinkActive, type ItemContext, label, } from "./items" @@ -35,7 +34,8 @@ import { sectionGroupHeader, strong, treeRow, treeSection } from "./tree" const MAX_DISCOVERABLE = 5 interface SectionsContext<M> extends ItemContext { - readonly activeChannelId: string | undefined + /** The "Members" link (the org home) is the current page. */ + readonly isMembersActive: boolean readonly onActiveMount: Attribute<M> readonly onBrowseChannels: Attribute<M> /** The row menus' inputs; each row builds its own menu inside its memo. */ @@ -107,9 +107,6 @@ const channelRowView = <M>( ), }) -const openRowMenu = (openMenu: Model["openMenu"], channelId: string): Menu.Model | null => - openMenu !== null && openMenu.target === `channel:${channelId}` ? openMenu.menu : null - /** Favorites render `ChannelItem` without `partnerOrgs`, so they never show the shared-org badge. */ const channelRow = <M>( h: HtmlBuilder<M>, @@ -125,7 +122,7 @@ const channelRow = <M>( entry, context.unreadByChannel.get(id) ?? entry.member.notificationCount, showsPartners ? (context.partnersByChannel.get(id) ?? NO_PARTNERS) : NO_PARTNERS, - isChannelLinkActive(context.pathname, context.orgSlug, id), + id === context.activeChannelId, context.orgSlug, place.treeId, place.position, @@ -190,7 +187,7 @@ const dmRow = <M>( // Only a single DM's status dot reads the clock. only === undefined ? 0 : context.nowMs, context.currentUserId, - isChannelLinkActive(context.pathname, context.orgSlug, channel.id), + channel.id === context.activeChannelId, context.orgSlug, place.treeId, place.position, @@ -261,7 +258,7 @@ const gotoSection = <M>(h: HtmlBuilder<M>, context: SectionsContext<M>): Html => h, { href: membersHref, - isActive: context.pathname === membersHref, + isActive: context.isMembersActive, activeClassName: CHANNEL_ACTIVE, }, [IconUsers(h), label(h, ["Members"])], @@ -387,8 +384,8 @@ const partnersOf = (model: Model) => { export const sectionGroupContent = <M>( h: HtmlBuilder<M>, model: Model, - itemContext: Omit<ItemContext, "presenceByUser" | "nowMs" | "currentUserId"> & { - readonly activeChannelId: string | undefined + itemContext: Omit<ItemContext, "presenceByUser" | "currentUserId"> & { + readonly route: AppRoute readonly onActiveMount: Attribute<M> readonly onBrowseChannels: Attribute<M> readonly onDismissCreateChannelHint: Attribute<M> @@ -398,8 +395,8 @@ export const sectionGroupContent = <M>( ): Html[] => { const context: SectionsContext<M> = { ...itemContext, + isMembersActive: itemContext.route._tag === "OrgHome", currentUserId: model.currentUserId, - nowMs: model.nowMs, presenceByUser: new Map(model.presence.map((presence) => [presence.userId, presence])), unreadByChannel: new Map(model.unreadCounts.map((unread) => [unread.channelId, unread.count])), partnersByChannel: partnersOf(model), diff --git a/apps/web-foldkit/src/shell/context.ts b/apps/web-foldkit/src/shell/context.ts index 07fabd464..0bdd824d4 100644 --- a/apps/web-foldkit/src/shell/context.ts +++ b/apps/web-foldkit/src/shell/context.ts @@ -1,14 +1,13 @@ +import type { AppRoute } from "../route" + /** What the shell's views read from the root on every render (passed down, never stored). */ export interface ShellContext { readonly orgSlug: string - readonly pathname: string + /** The current route; active links are derived from it, never from the URL string. */ + readonly route: AppRoute readonly organization: { readonly name: string; readonly logoUrl: string | null } | undefined readonly currentUser: | { readonly displayName: string; readonly email: string; readonly avatarUrl: string | null } | undefined readonly appVersion: string } - -/** TanStack's default (non-exact) active match: the path equals or is nested under `to`. */ -export const isActiveFuzzy = (pathname: string, to: string) => - pathname === to || pathname.startsWith(`${to}/`) diff --git a/apps/web-foldkit/src/shell/layouts.ts b/apps/web-foldkit/src/shell/layouts.ts index 9c511a3d4..3f2fac10f 100644 --- a/apps/web-foldkit/src/shell/layouts.ts +++ b/apps/web-foldkit/src/shell/layouts.ts @@ -48,9 +48,9 @@ export const channelSettingsLayout = <Message>( channel: ChannelSummary | null selectedTab: ChannelSettingsTab /** Tabs navigate on selection, as `onSelectionChange` does. */ - onSelectTab: (href: string) => Attribute<Message> - /** The mobile `<select>` navigates on change, with the chosen option's href. */ - onChangeTab: (toHref: (tab: string) => string) => Attribute<Message> + onSelectTab: (route: AppRoute) => Attribute<Message> + /** The mobile `<select>` navigates on change, to the chosen option's route. */ + onChangeTab: (toRoute: (tab: string) => AppRoute) => Attribute<Message> }>, page: Html, ): Html => { @@ -60,7 +60,7 @@ export const channelSettingsLayout = <Message>( integrations: AppRoute.ChannelSettingsIntegrations, connect: AppRoute.ChannelSettingsConnect, } as const - const tabHref = (tab: ChannelSettingsTab) => hrefOf(tabRoutes[tab]({ orgSlug, channelId })) + const tabRoute = (tab: ChannelSettingsTab): AppRoute => tabRoutes[tab]({ orgSlug, channelId }) return h.main( [h.Class("h-full w-full min-w-0 bg-bg")], [ @@ -147,7 +147,7 @@ export const channelSettingsLayout = <Message>( ), h.Value(options.selectedTab), options.onChangeTab((tab) => - tabHref( + tabRoute( isChannelSettingsTab(tab) ? tab : options.selectedTab, ), ), @@ -181,7 +181,7 @@ export const channelSettingsLayout = <Message>( id: tab, label: tabLabels[tab], isSelected: tab === options.selectedTab, - attributes: [options.onSelectTab(tabHref(tab))], + attributes: [options.onSelectTab(tabRoute(tab))], }), ), ), @@ -256,6 +256,25 @@ const stalledNotice = <Message>(h: HtmlBuilder<Message>, onReload: Message): Htm ], ) +/** + * A full-page error the app cannot get past on its own (the API did not answer, `user.me` failed): + * what happened, and a way to try again. + */ +export const appError = <Message>( + h: HtmlBuilder<Message>, + options: Readonly<{ title: string; description: string | undefined; onRetry: Message }>, +): Html => + h.div( + [h.Class("flex h-screen items-center justify-center p-6"), h.Role("alert")], + [ + emptyState(h, { + title: options.title, + ...(options.description === undefined ? {} : { description: options.description }), + action: button(h, { intent: "primary", onPress: options.onRetry }, ["Try again"]), + }), + ], + ) + /** The route names an organization the user cannot see: it does not exist or they are not a member. */ export const organizationNotFound = <Message>(h: HtmlBuilder<Message>, orgSlug: string): Html => h.div( diff --git a/apps/web-foldkit/src/shell/menus.ts b/apps/web-foldkit/src/shell/menus.ts index ca41fad92..0778c9f74 100644 --- a/apps/web-foldkit/src/shell/menus.ts +++ b/apps/web-foldkit/src/shell/menus.ts @@ -1,4 +1,5 @@ import type { UserId } from "@hazel/schema" +import { Match, Option, Schema } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { twJoin } from "tailwind-merge" import { @@ -22,7 +23,7 @@ import * as Menu from "../ui/menu" import { menuLabel, menuTriggerClassName, view as menuView } from "../ui/menu-view" import { sidebarFooter, sidebarHeader, sidebarLabel } from "../ui/sidebar" import type { ShellContext } from "./context" -import type { UserStatus } from "./model" +import type { SwitcherOrg, UserStatus } from "./model" import { sidebarHeaderHeightClass } from "../platform/desktop" /** The user menu (`sidebar/user-menu.tsx`) and the org switcher (`channels-sidebar.tsx` header). */ @@ -30,24 +31,65 @@ import { sidebarHeaderHeightClass } from "../platform/desktop" export const USER_MENU_ID = "user-menu" export const ORG_SWITCHER_ID = "org-switcher" -export interface SwitcherOrg { - readonly id: string - readonly name: string - readonly slug: string | null - readonly logoUrl: string | null -} +/** The user menu's items; `status`, `feedback` and `logout` act, the others are links. */ +export const UserMenuKey = Schema.Literals(["header", "profile", "status", "my-settings", "feedback", "logout"]) +export type UserMenuKey = typeof UserMenuKey.Type + +/** The org switcher's fixed items; each organization's item is keyed by its id. */ +export const SwitcherKey = Schema.Literals([ + "switch-server", + "create-server", + "invite-people", + "manage-members", + "create-channel", + "create-category", + "server-settings", + "custom-emojis", + "integrations", +]) +export type SwitcherKey = typeof SwitcherKey.Type + +export const decodeUserMenuKey = Schema.decodeUnknownOption(UserMenuKey) +export const decodeSwitcherKey = Schema.decodeUnknownOption(SwitcherKey) + +/** Where a user menu link item goes. */ +export const userMenuLinkRoute = (key: UserMenuKey, orgSlug: string, userId: UserId): Option.Option<AppRoute> => + Match.value(key).pipe( + Match.when("profile", () => Option.some(AppRoute.Profile({ orgSlug, userId }))), + Match.when("my-settings", () => Option.some(AppRoute.MySettingsAppearance({ orgSlug }))), + Match.orElse(() => Option.none()), + ) + +/** Where an org switcher link item goes. */ +export const switcherLinkRoute = (key: SwitcherKey, orgSlug: string): Option.Option<AppRoute> => + Match.value(key).pipe( + Match.when("manage-members", () => Option.some(AppRoute.TeamSettings({ orgSlug }))), + Match.when("server-settings", () => Option.some(AppRoute.SettingsGeneral({ orgSlug }))), + Match.when("custom-emojis", () => Option.some(AppRoute.SettingsCustomEmojis({ orgSlug }))), + Match.when("integrations", () => Option.some(AppRoute.SettingsIntegrations({ orgSlug }))), + Match.orElse(() => Option.none()), + ) + +const hrefOption = (route: Option.Option<AppRoute>) => + Option.match(route, { onNone: () => ({}), onSome: (found) => ({ href: hrefOf(found) }) }) + +type ItemOptions = Parameters<typeof Menu.item>[1] +const userItem = (key: UserMenuKey, orgSlug: string, userId: UserId, options?: ItemOptions) => + Menu.item(key, { ...options, ...hrefOption(userMenuLinkRoute(key, orgSlug, userId)) }) +const switcherItem = (key: SwitcherKey, orgSlug: string, options?: ItemOptions) => + Menu.item(key, { ...options, ...hrefOption(switcherLinkRoute(key, orgSlug)) }) // ENTRIES export const userMenuEntries = (orgSlug: string, userId: UserId): ReadonlyArray<Menu.Entry> => [ - Menu.section(undefined, [], { header: { key: "header", hasSeparator: true } }), - Menu.item("profile", { href: hrefOf(AppRoute.Profile({ orgSlug, userId })) }), - Menu.item("status"), - Menu.item("my-settings", { href: hrefOf(AppRoute.MySettingsAppearance({ orgSlug })) }), + Menu.section(undefined, [], { header: { key: "header" satisfies UserMenuKey, hasSeparator: true } }), + userItem("profile", orgSlug, userId), + userItem("status", orgSlug, userId), + userItem("my-settings", orgSlug, userId), Menu.separator, - Menu.item("feedback"), + userItem("feedback", orgSlug, userId), Menu.separator, - Menu.item("logout"), + userItem("logout", orgSlug, userId), ] /** `SwitchServerMenu` at the root: the orgs section, a separator, then "Create server". */ @@ -55,11 +97,11 @@ const switchServerEntries = (organizations: ReadonlyArray<SwitcherOrg>): Readonl Menu.section( undefined, organizations.map((organization) => - Menu.item(`org:${organization.id}`, { textValue: organization.name }), + Menu.item(organization.id, { textValue: organization.name }), ), ), Menu.separator, - Menu.item("create-server", { textValue: "Create server" }), + Menu.item("create-server" satisfies SwitcherKey, { textValue: "Create server" }), ] /** Chat routes get the full menu, settings and my-settings a shorter one; mobile only switches orgs. */ @@ -73,17 +115,17 @@ export const orgSwitcherEntries = (options: { if (options.isMobile) return switchServerEntries(options.organizations) const { orgSlug } = options // NOTE: legacy renders the orgs as a single-selection section plus a separator; the kit submenu takes leaves. - const switchServer = Menu.item("switch-server", { + const switchServer = switcherItem("switch-server", orgSlug, { submenu: [ ...options.organizations.map((organization) => - Menu.leaf(`org:${organization.id}`, { textValue: organization.name }), + Menu.leaf(organization.id, { textValue: organization.name }), ), - Menu.leaf("create-server", { textValue: "Create server" }), + Menu.leaf("create-server" satisfies SwitcherKey, { textValue: "Create server" }), ], }) const people = Menu.section(undefined, [ - Menu.item("invite-people"), - Menu.item("manage-members", { href: hrefOf(AppRoute.TeamSettings({ orgSlug })) }), + switcherItem("invite-people", orgSlug), + switcherItem("manage-members", orgSlug), ]) if (!options.isChat) return [ @@ -91,7 +133,7 @@ export const orgSwitcherEntries = (options: { switchServer, Menu.separator, Menu.section(undefined, [ - Menu.item("server-settings", { href: hrefOf(AppRoute.SettingsGeneral({ orgSlug })) }), + switcherItem("server-settings", orgSlug), ]), ] return [ @@ -99,14 +141,14 @@ export const orgSwitcherEntries = (options: { switchServer, Menu.separator, Menu.section(undefined, [ - ...(options.canCreateChannel ? [Menu.item("create-channel")] : []), - Menu.item("create-category"), + ...(options.canCreateChannel ? [switcherItem("create-channel", orgSlug)] : []), + switcherItem("create-category", orgSlug), ]), Menu.separator, Menu.section(undefined, [ - Menu.item("server-settings", { href: hrefOf(AppRoute.SettingsGeneral({ orgSlug })) }), - Menu.item("custom-emojis", { href: hrefOf(AppRoute.SettingsCustomEmojis({ orgSlug })) }), - Menu.item("integrations", { href: hrefOf(AppRoute.SettingsIntegrations({ orgSlug })) }), + switcherItem("server-settings", orgSlug), + switcherItem("custom-emojis", orgSlug), + switcherItem("integrations", orgSlug), ]), ] } @@ -151,31 +193,36 @@ const userMenuContent = (key: string): ReadonlyArray<Html> => { const displayName = context.currentUser?.displayName ?? "User" const item = (icon: Html, text: string) => [icon, label(h, USER_MENU_ID, key, text)] - if (key === "header") - return [ - h.span([h.Class("block")], [displayName]), - ...(context.currentUser?.email - ? [ - h.span( - [h.Class("block truncate font-normal text-muted-fg")], - [context.currentUser.email], - ), - ] - : []), - ] - if (key === "profile") return item(IconProfiles2(h), "Profile") - if (key === "status") - return [ - IconEmoji1(h), - menuLabel(h, USER_MENU_ID, key, [ - "Set status", - ...(status?.emoji ? [h.span([h.Class("ml-1")], [status.emoji])] : []), - ]), - ] - if (key === "my-settings") return item(IconGear(h), "My Settings") - if (key === "feedback") return item(IconSupport(h), "Feedback") - if (key === "logout") return item(IconLogout(h), "Log out") - return [] + return Option.match(decodeUserMenuKey(key), { + onNone: (): ReadonlyArray<Html> => [], + onSome: (userKey) => + Match.value(userKey).pipe( + Match.withReturnType<ReadonlyArray<Html>>(), + Match.when("header", () => [ + h.span([h.Class("block")], [displayName]), + ...(context.currentUser?.email + ? [ + h.span( + [h.Class("block truncate font-normal text-muted-fg")], + [context.currentUser.email], + ), + ] + : []), + ]), + Match.when("profile", () => item(IconProfiles2(h), "Profile")), + Match.when("status", () => [ + IconEmoji1(h), + menuLabel(h, USER_MENU_ID, key, [ + "Set status", + ...(status?.emoji ? [h.span([h.Class("ml-1")], [status.emoji])] : []), + ]), + ]), + Match.when("my-settings", () => item(IconGear(h), "My Settings")), + Match.when("feedback", () => item(IconSupport(h), "Feedback")), + Match.when("logout", () => item(IconLogout(h), "Log out")), + Match.exhaustive, + ), + }) } /** `SidebarFooter` > `UserMenu`. */ @@ -265,7 +312,7 @@ const switcherContent = <M>(h: HtmlBuilder<M>, organizations: ReadonlyArray<SwitcherOrg>) => (key: string): ReadonlyArray<Html> => { const item = (icon: Html, text: string) => [icon, label(h, ORG_SWITCHER_ID, key, text)] - const organization = organizations.find((candidate) => `org:${candidate.id}` === key) + const organization = organizations.find((candidate) => candidate.id === key) if (organization) return [ avatar(h, { @@ -276,17 +323,26 @@ const switcherContent = }), sidebarLabel(h, [organization.name]), ] - if (key === "create-server") - return [IconPlus(h, { attributes: { "data-slot": "icon" } }), sidebarLabel(h, ["Create server"])] - if (key === "invite-people") return item(IconUsersPlus(h), "Invite people") - if (key === "manage-members") return item(IconUsers(h), "Manage members") - if (key === "switch-server") return item(IconServers(h), "Switch Server") - if (key === "create-channel") return item(IconCirclePlus(h), "Create channel") - if (key === "create-category") return item(IconFolderPlus(h), "Create category") - if (key === "server-settings") return item(IconGear(h), "Server settings") - if (key === "custom-emojis") return item(IconEmoji1(h), "Custom emojis") - if (key === "integrations") return item(IconIntegratio(h), "Integrations") - return [] + return Option.match(decodeSwitcherKey(key), { + onNone: (): ReadonlyArray<Html> => [], + onSome: (switcherKey) => + Match.value(switcherKey).pipe( + Match.withReturnType<ReadonlyArray<Html>>(), + Match.when("create-server", () => [ + IconPlus(h, { attributes: { "data-slot": "icon" } }), + sidebarLabel(h, ["Create server"]), + ]), + Match.when("invite-people", () => item(IconUsersPlus(h), "Invite people")), + Match.when("manage-members", () => item(IconUsers(h), "Manage members")), + Match.when("switch-server", () => item(IconServers(h), "Switch Server")), + Match.when("create-channel", () => item(IconCirclePlus(h), "Create channel")), + Match.when("create-category", () => item(IconFolderPlus(h), "Create category")), + Match.when("server-settings", () => item(IconGear(h), "Server settings")), + Match.when("custom-emojis", () => item(IconEmoji1(h), "Custom emojis")), + Match.when("integrations", () => item(IconIntegratio(h), "Integrations")), + Match.exhaustive, + ), + }) } /** `SidebarHeader` > org switcher `Menu` (channels, settings and my-settings sidebars). */ diff --git a/apps/web-foldkit/src/shell/mobile.ts b/apps/web-foldkit/src/shell/mobile.ts index 5c93c5e19..acd32edb3 100644 --- a/apps/web-foldkit/src/shell/mobile.ts +++ b/apps/web-foldkit/src/shell/mobile.ts @@ -1,11 +1,18 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { IconBell, IconDashboard, IconGear, IconMenu, IconMsgs } from "../icons" -import { AppRoute, hrefOf } from "../route" +import { + AppRoute, + hrefOf, + isChannelChatRoute, + isChatRoute, + isNotificationsRoute, + isSettingsRoute, +} from "../route" import { button } from "../ui/button" import type * as Modal from "../ui/modal" import * as Sheet from "../ui/sheet" -import { isActiveFuzzy, type ShellContext } from "./context" +import type { ShellContext } from "./context" /** The mobile shell (`useSidebar().isMobile`): bottom nav, the sidebar sheet and its menu buttons. */ @@ -24,11 +31,10 @@ export const mobileNav = <Message>( const chat = hrefOf(AppRoute.ChatIndex({ orgSlug })) const notifications = hrefOf(AppRoute.NotificationsAll({ orgSlug })) const settings = hrefOf(AppRoute.SettingsGeneral({ orgSlug })) - const path = context.pathname - const link = (label: string, href: string, isActive: boolean, icon: Html) => { - // TanStack `Link` marks any fuzzy match active, independently of the colour rule above. - const isLinkActive = isActiveFuzzy(path, href) - return h.a( + const { route } = context + /** `isActive` colours the item; `isLinkActive` is TanStack `Link`'s own (fuzzy) match. */ + const link = (label: string, href: string, isActive: boolean, isLinkActive: boolean, icon: Html) => + h.a( [ h.Class( twMerge( @@ -44,8 +50,6 @@ export const mobileNav = <Message>( ], [icon, h.span([h.Class("font-medium text-[10px]")], [label])], ) - } - const chatPrefix = `${chat}/` return h.nav( [ h.Class( @@ -67,23 +71,32 @@ export const mobileNav = <Message>( h.span([h.Class("font-medium text-[10px]")], ["Menu"]), ], ), - link("Home", home, path === home, IconDashboard(h, { className: "size-6" })), + link( + "Home", + home, + route._tag === "OrgHome", + route._tag === "OrgHome", + IconDashboard(h, { className: "size-6" }), + ), link( "Messages", chat, - path.startsWith(chatPrefix) && path.length > chatPrefix.length, + isChannelChatRoute(route), + isChatRoute(route), IconMsgs(h, { className: "size-6" }), ), link( "Activity", notifications, - isActiveFuzzy(path, notifications), + isNotificationsRoute(route), + isNotificationsRoute(route), IconBell(h, { className: "size-6" }), ), link( "Settings", settings, - isActiveFuzzy(path, settings), + isSettingsRoute(route), + isSettingsRoute(route), IconGear(h, { className: "size-6" }), ), ], diff --git a/apps/web-foldkit/src/shell/model.ts b/apps/web-foldkit/src/shell/model.ts index aa4a973b3..62976f2ed 100644 --- a/apps/web-foldkit/src/shell/model.ts +++ b/apps/web-foldkit/src/shell/model.ts @@ -10,12 +10,14 @@ import * as Notifications from "./notifications" // MODEL +/** An organization in the switcher (`SwitchServerMenu`). */ export const SwitcherOrg = Schema.Struct({ id: OrganizationId, name: Schema.String, slug: Schema.NullOr(Schema.String), logoUrl: Schema.NullOr(Schema.String), }) +export type SwitcherOrg = typeof SwitcherOrg.Type export const ChannelSummary = Schema.Struct({ id: ChannelId, @@ -36,16 +38,12 @@ export const Model = Schema.Struct({ channelsSidebar: ChannelsSidebar.Model, userMenu: Menu.Model, orgSwitcher: Menu.Model, - /** The inputs the menus' entries were last built from; rebuilt only when this changes. */ - menuSignature: Schema.String, userOrganizations: Schema.Array(SwitcherOrg), userStatus: Schema.NullOr(UserStatus), /** Unread notification ids and the optimistic "Mark all as read" state. */ notifications: Notifications.Model, /** `channels/$channelId/settings/layout.tsx` header (name and icon). */ settingsChannel: Schema.NullOr(ChannelSummary), - /** `useSidebar().isMobile`: the `(max-width: 767px)` media query. */ - isMobile: Schema.Boolean, /** `isOpenOnMobile`: the sidebar sheet opened from the header or the bottom nav. */ isSidebarOpen: Schema.Boolean, }) @@ -62,7 +60,6 @@ export const Message = defineMessageUnion({ GotNotificationsMessage: { message: Notifications.Message }, UpdatedSettingsChannel: { channel: Schema.NullOr(ChannelSummary) }, ToggledSidebar: { isOpen: Schema.Boolean }, - ChangedViewport: { isMobile: Schema.Boolean }, GotMobileSidebarMessage: { message: Modal.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/shell/notifications.test.ts b/apps/web-foldkit/src/shell/notifications.test.ts index 3437dd8ab..cb6c3666e 100644 --- a/apps/web-foldkit/src/shell/notifications.test.ts +++ b/apps/web-foldkit/src/shell/notifications.test.ts @@ -62,7 +62,7 @@ describe("mark all as read", () => { ), Story.Command.resolve( MarkNotificationRead({ id: second }), - Message.FailedMarkNotificationRead({ id: second, reason: "offline" }), + Message.FailedMarkNotificationRead({ id: second, reason: "Offline" }), ), Story.Command.resolve( MarkNotificationRead({ id: third }), diff --git a/apps/web-foldkit/src/shell/notifications.ts b/apps/web-foldkit/src/shell/notifications.ts index eb4bac211..23f26498c 100644 --- a/apps/web-foldkit/src/shell/notifications.ts +++ b/apps/web-foldkit/src/shell/notifications.ts @@ -3,6 +3,8 @@ import { Clock, Effect, Schema } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" import { HazelRpc } from "../rpc" /** "Mark all as read" (`markAllAsRead` in `hooks/use-notifications.ts`), owned by the shell. */ @@ -27,7 +29,7 @@ export const Message = defineMessageUnion({ UpdatedUnreadNotifications: { ids: Schema.Array(NotificationId) }, ClickedMarkAllRead: {}, SucceededMarkNotificationRead: { id: NotificationId }, - FailedMarkNotificationRead: { id: NotificationId, reason: Schema.String }, + FailedMarkNotificationRead: { id: NotificationId, reason: Schema.Union([Schema.Literal("NotFound"), CommonReason]) }, }) export type Message = typeof Message.Type @@ -44,9 +46,10 @@ export const MarkNotificationRead = Command.define("MarkNotificationRead", { yield* client("notification.update", { id, readAt: new Date(nowMs) }) return Message.SucceededMarkNotificationRead({ id }) }).pipe( - Effect.catch((error) => - Effect.succeed(Message.FailedMarkNotificationRead({ id, reason: String(error) })), + Effect.catchTag("NotificationNotFoundError", () => + Effect.succeed(Message.FailedMarkNotificationRead({ id, reason: "NotFound" })), ), + catchCommon((reason) => Message.FailedMarkNotificationRead({ id, reason })), ), }) @@ -65,7 +68,13 @@ const without = (ids: ReadonlyArray<NotificationId>, id: NotificationId) => export const update = (model: Model, message: Message): Update.Return<Model, Message, HazelRpc> => Message.match<Update.Return<Model, Message, HazelRpc>>(message, { - UpdatedUnreadNotifications: ({ ids }) => ({ model: modifyFields(model, { unreadIds: () => ids }) }), + // An optimistic id that left the unread rows has synced as read: it is no longer needed. + UpdatedUnreadNotifications: ({ ids }) => ({ + model: modifyFields(model, { + unreadIds: () => ids, + optimisticReadIds: (optimistic) => optimistic.filter((id) => ids.includes(id)), + }), + }), // Every unread id is marked read at once and the calls go out in parallel, across all tabs. ClickedMarkAllRead: () => { const ids = unreadIdsOf(model) @@ -81,7 +90,7 @@ export const update = (model: Model, message: Message): Update.Return<Model, Mes SucceededMarkNotificationRead: ({ id }) => ({ model: modifyFields(model, { pendingIds: (ids) => without(ids, id) }), }), - // A failed call rolls its id back, so it counts as unread again. + // A failed call rolls its id back, so it counts as unread again (the count is the feedback, no toast). FailedMarkNotificationRead: ({ id }) => ({ model: modifyFields(model, { pendingIds: (ids) => without(ids, id), diff --git a/apps/web-foldkit/src/shell/sidebars.ts b/apps/web-foldkit/src/shell/sidebars.ts index d8440d1b3..19b63d74e 100644 --- a/apps/web-foldkit/src/shell/sidebars.ts +++ b/apps/web-foldkit/src/shell/sidebars.ts @@ -17,7 +17,7 @@ import { IconUsers, IconUsersPlus, } from "../icons" -import { AppRoute, hrefOf, linkedAccountsHref } from "../route" +import { AppRoute, hrefOf, isNotificationsRoute, linkedAccountsHref, type RouteTag } from "../route" import { button } from "../ui/button" import { sidebarContent, @@ -30,7 +30,7 @@ import { sidebarStatic, } from "../ui/sidebar" import type { SidebarChrome } from "./channels-sidebar" -import { isActiveFuzzy, type ShellContext } from "./context" +import type { ShellContext } from "./context" import { orgSwitcherHeader, userMenuFooter } from "./menus" import { isDesktop, sidebarHeaderHeightClass } from "../platform/desktop" @@ -56,6 +56,12 @@ const navList = <Message>(h: HtmlBuilder<Message>) => { return item } +/** An item that is current (and its link active) on exactly these routes. */ +const onRoutes = (route: AppRoute, ...tags: ReadonlyArray<RouteTag>) => { + const isOn = tags.includes(route._tag) + return { isCurrent: isOn, isLinkActive: isOn } +} + const sectionTitle = <Message>(h: HtmlBuilder<Message>, title: string) => h.div( [h.Class("px-3 pt-3 pb-1")], @@ -79,16 +85,8 @@ export const settingsSidebar = <Message>( const yourApps = hrefOf(AppRoute.SettingsIntegrationsYourApps({ orgSlug })) const connectInvites = hrefOf(AppRoute.SettingsConnectInvites({ orgSlug })) const chatSync = hrefOf(AppRoute.SettingsChatSync({ orgSlug })) - const path = context.pathname + const { route } = context const item = navList(h) - const fuzzy = (to: string) => ({ - isCurrent: isActiveFuzzy(path, to), - isLinkActive: isActiveFuzzy(path, to), - }) - const integrationSubRoutes = [marketplace, installed, yourApps] - const isBaseIntegrations = - path === integrations || - (isActiveFuzzy(path, integrations) && !integrationSubRoutes.some((sub) => isActiveFuzzy(path, sub))) return sidebarStatic(h, "flex flex-1", [ orgSwitcherHeader(h, chrome.orgSwitcher, context, chrome.organizations, chrome.toOrgSwitcherMessage), @@ -96,40 +94,46 @@ export const settingsSidebar = <Message>( sidebarSectionGroup(h, [ sectionTitle(h, "Settings"), sidebarSection(h, {}, [ - item( - "General", - general, - { isCurrent: path === general, isLinkActive: path === general }, - IconGear, - ), - item( - "Team", - team, - { - isCurrent: path === team, - isLinkActive: isActiveFuzzy(path, team), - }, - IconUsers, - ), - item("Invitations", invitations, fuzzy(invitations), IconUsersPlus), - item("Custom Emoji", customEmojis, fuzzy(customEmojis), IconEmojiAdd), + item("General", general, onRoutes(route, "SettingsGeneral"), IconGear), + item("Team", team, onRoutes(route, "TeamSettings"), IconUsers), + item("Invitations", invitations, onRoutes(route, "SettingsInvitations"), IconUsersPlus), + item("Custom Emoji", customEmojis, onRoutes(route, "SettingsCustomEmojis"), IconEmojiAdd), ]), sidebarSection(h, { label: "Apps & Integrations" }, [ + // An integration's own page (`integrations/$id`) keeps "Integrations" current. item( "Integrations", integrations, - { isCurrent: isBaseIntegrations, isLinkActive: path === integrations }, + { + isCurrent: onRoutes(route, "SettingsIntegrations", "SettingsIntegration").isCurrent, + isLinkActive: route._tag === "SettingsIntegrations", + }, IconIntegratio, ), - item("Marketplace", marketplace, fuzzy(marketplace), IconShop), - item("Installed Apps", installed, fuzzy(installed), IconGridCirclePlus), - item("Your Apps", yourApps, fuzzy(yourApps), IconCode), + item("Marketplace", marketplace, onRoutes(route, "SettingsIntegrationsMarketplace"), IconShop), + item( + "Installed Apps", + installed, + onRoutes(route, "SettingsIntegrationsInstalled"), + IconGridCirclePlus, + ), + item("Your Apps", yourApps, onRoutes(route, "SettingsIntegrationsYourApps"), IconCode), ]), sidebarSection(h, { label: "Hazel Connect" }, [ - item("Connect Invites", connectInvites, fuzzy(connectInvites), IconArrowPath), + item( + "Connect Invites", + connectInvites, + onRoutes(route, "SettingsConnectInvites"), + IconArrowPath, + ), ]), sidebarSection(h, { label: "Chat Sync" }, [ - item("Connections", chatSync, fuzzy(chatSync), IconArrowPath), + item( + "Connections", + chatSync, + onRoutes(route, "SettingsChatSync", "SettingsChatSyncConnection"), + IconArrowPath, + ), ]), ]), ]), @@ -156,28 +160,26 @@ export const mySettingsSidebar = <Message>( const linkedAccounts = linkedAccountsHref(orgSlug) const notifications = hrefOf(AppRoute.MySettingsNotifications({ orgSlug })) const desktop = hrefOf(AppRoute.MySettingsDesktop({ orgSlug })) - const path = context.pathname + const { route } = context const item = navList(h) - const fuzzy = (to: string) => ({ - isCurrent: isActiveFuzzy(path, to), - isLinkActive: isActiveFuzzy(path, to), - }) return sidebarStatic(h, "flex flex-1", [ orgSwitcherHeader(h, chrome.orgSwitcher, context, chrome.organizations, chrome.toOrgSwitcherMessage), sidebarContent(h, { state: "expanded" }, [ sidebarSectionGroup(h, [ sectionTitle(h, "My Settings"), sidebarSection(h, {}, [ + item("Appearance", appearance, onRoutes(route, "MySettingsAppearance"), IconPaintbrush), + item("Profile", profile, onRoutes(route, "MySettingsProfile"), IconUser), item( - "Appearance", - appearance, - { isCurrent: path === appearance, isLinkActive: path === appearance }, - IconPaintbrush, + "Linked Accounts", + linkedAccounts, + onRoutes(route, "MySettingsLinkedAccounts"), + IconArrowPath, ), - item("Profile", profile, fuzzy(profile), IconUser), - item("Linked Accounts", linkedAccounts, fuzzy(linkedAccounts), IconArrowPath), - item("Notifications", notifications, fuzzy(notifications), IconBell), - ...(isDesktop ? [item("Desktop", desktop, fuzzy(desktop), IconCube)] : []), + item("Notifications", notifications, onRoutes(route, "MySettingsNotifications"), IconBell), + ...(isDesktop + ? [item("Desktop", desktop, onRoutes(route, "MySettingsDesktop"), IconCube)] + : []), ]), ]), ]), @@ -204,12 +206,8 @@ export const notificationsSidebar = <Message>( const general = hrefOf(AppRoute.NotificationsGeneral({ orgSlug })) const threads = hrefOf(AppRoute.NotificationsThreads({ orgSlug })) const dms = hrefOf(AppRoute.NotificationsDms({ orgSlug })) - const path = context.pathname + const { route } = context const item = navList(h) - const fuzzy = (to: string) => ({ - isCurrent: isActiveFuzzy(path, to), - isLinkActive: isActiveFuzzy(path, to), - }) return sidebarStatic(h, "flex flex-1", [ sidebarHeader(h, { state: "expanded", className: `border-b py-4 ${sidebarHeaderHeightClass()}` }, [ h.span([h.Class("text-muted-fg text-xs font-medium uppercase tracking-wider")], ["Activity"]), @@ -221,12 +219,12 @@ export const notificationsSidebar = <Message>( item( "All Activity", all, - { isCurrent: isActiveFuzzy(path, all), isLinkActive: path === all }, + { isCurrent: isNotificationsRoute(route), isLinkActive: route._tag === "NotificationsAll" }, IconBell, ), - item("Channels", general, fuzzy(general), IconHashtag), - item("Threads", threads, fuzzy(threads), IconThread), - item("Direct Messages", dms, fuzzy(dms), IconMsgs), + item("Channels", general, onRoutes(route, "NotificationsGeneral"), IconHashtag), + item("Threads", threads, onRoutes(route, "NotificationsThreads"), IconThread), + item("Direct Messages", dms, onRoutes(route, "NotificationsDms"), IconMsgs), ]), ...(markAllRead.unreadCount > 0 ? [ diff --git a/apps/web-foldkit/src/shell/subscription.ts b/apps/web-foldkit/src/shell/subscription.ts index de4058526..71fa5ddc0 100644 --- a/apps/web-foldkit/src/shell/subscription.ts +++ b/apps/web-foldkit/src/shell/subscription.ts @@ -1,7 +1,7 @@ import { ChannelId, NotificationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { and, eq, isNull } from "@tanstack/db" import { Option, Schema, Stream } from "effect" -import { Dom, Subscription } from "foldkit" +import { Subscription } from "foldkit" import { channelCollection, notificationCollection, @@ -11,7 +11,7 @@ import { } from "~/db/collections" import { liveQueryStream } from "../data/live-query" import type { Shared } from "../page/contract" -import { type AppRoute, orgSectionOf } from "../route" +import { AppRoute, orgSectionOf } from "../route" import * as ChannelsSidebar from "./channels-sidebar" import * as Notifications from "./notifications" import { type ChannelSummary, Message, type Model } from "./model" @@ -38,21 +38,16 @@ interface PresenceRow { readonly statusExpiresAt?: Date | null } -const settingsChannelId = (route: AppRoute) => - route._tag === "ChannelSettingsOverview" || - route._tag === "ChannelSettingsIntegrations" || - route._tag === "ChannelSettingsConnect" - ? route.channelId - : null +/** The channel settings tabs, whose layout header shows the channel. */ +const isChannelSettingsTab = AppRoute.isAnyOf([ + "ChannelSettingsOverview", + "ChannelSettingsIntegrations", + "ChannelSettingsConnect", +]) + +const settingsChannelId = (route: AppRoute) => (isChannelSettingsTab(route) ? route.channelId : null) const own = Subscription.make<Input, Message>()((entry) => ({ - // `useMediaQuery("(max-width: 767px)")` in `SidebarProvider`. - shellViewport: Subscription.persistentEntry( - Dom.streamFromMediaQuery({ - query: "(max-width: 767px)", - mapMatches: (isMobile) => Message.ChangedViewport({ isMobile }), - }), - ), // `SwitchServerMenu`: every organization the user belongs to, oldest membership first. shellUserOrganizations: entry( { userId: Schema.NullOr(UserId) }, diff --git a/apps/web-foldkit/src/shell/update.ts b/apps/web-foldkit/src/shell/update.ts index 4e88e1cdd..4b6e4281c 100644 --- a/apps/web-foldkit/src/shell/update.ts +++ b/apps/web-foldkit/src/shell/update.ts @@ -1,14 +1,25 @@ -import type { OrganizationId, UserId } from "@hazel/schema" -import { Option } from "effect" -import { Command, Update } from "foldkit" +import { OrganizationId, type UserId } from "@hazel/schema" +import { Match, Option, Schema } from "effect" +import { Update } from "foldkit" import { modifyFields } from "foldkit/struct" +import type { ModalRequest } from "../overlay/modal" import { PageOutMessage } from "../page/out-message" -import { organizationHref } from "../route" +import { type AppRoute, organizationRoute } from "../route" import type { HazelRpc } from "../rpc" import * as Menu from "../ui/menu" import * as Modal from "../ui/modal" import * as ChannelsSidebar from "./channels-sidebar" -import { ORG_SWITCHER_ID, orgSwitcherEntries, USER_MENU_ID, userMenuEntries } from "./menus" +import { + decodeSwitcherKey, + decodeUserMenuKey, + ORG_SWITCHER_ID, + orgSwitcherEntries, + switcherLinkRoute, + type SwitcherKey, + USER_MENU_ID, + userMenuEntries, + userMenuLinkRoute, +} from "./menus" import { MOBILE_SIDEBAR_ID } from "./mobile" import * as Notifications from "./notifications" import { Message, type Model } from "./model" @@ -22,50 +33,39 @@ export interface Context { readonly canCreateChannel: boolean readonly organizationId: OrganizationId | null readonly currentUserId: UserId | null + /** `useSidebar().isMobile`: mobile shows a flat org list instead of the full switcher. */ + readonly isMobile: boolean } // INIT -export const init = (): Model => ({ - channelsSidebar: ChannelsSidebar.init(), +export const init = (options: { readonly isCreateChannelHintDismissed: boolean }): Model => ({ + channelsSidebar: ChannelsSidebar.init(options), userMenu: Menu.init({ id: USER_MENU_ID, entries: [], placement: "bottom right" }), orgSwitcher: Menu.init({ id: ORG_SWITCHER_ID, entries: [] }), - menuSignature: "", userOrganizations: [], userStatus: null, notifications: Notifications.init(), settingsChannel: null, - isMobile: false, isSidebarOpen: false, }) // UPDATE -export type ShellReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage> -/** What `update` returns: the shell's own Commands (mark-read RPCs) need `HazelRpc`. */ -export type ShellUpdateReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc> +export type ShellReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc> +type Step = Update.StepWithOutMessage<Model, Message, PageOutMessage, HazelRpc> -/** Rebuilds the menus' entries when one of their inputs changed (cheap no-op otherwise). */ +/** + * The menus' entries from the inputs they show. Rebuilt only where an input changes: the root's + * context (`informContext`) and the user's organizations. + */ const withMenuEntries = (model: Model, context: Context): Model => { - const { orgSlug, currentUserId } = context + const { orgSlug, currentUserId, isMobile } = context // The menus only render inside the org shell, which waits for `user.me`. if (orgSlug === null) return model - const signature = JSON.stringify([ - orgSlug, - context.currentUserId, - context.isChatSection, - context.canCreateChannel, - context.organizationId, - model.isMobile, - model.userOrganizations, - ]) - if (signature === model.menuSignature) return model return modifyFields(model, { - menuSignature: () => signature, userMenu: (menu) => - currentUserId === null - ? menu - : Menu.reflectEntries(menu, userMenuEntries(orgSlug, currentUserId)), + currentUserId === null ? menu : Menu.reflectEntries(menu, userMenuEntries(orgSlug, currentUserId)), orgSwitcher: (menu) => modifyFields( Menu.reflectEntries( @@ -73,97 +73,117 @@ const withMenuEntries = (model: Model, context: Context): Model => { orgSwitcherEntries({ orgSlug, isChat: context.isChatSection, - isMobile: model.isMobile, + isMobile, canCreateChannel: context.canCreateChannel, organizations: model.userOrganizations, }), ), // Mobile shows `SwitchServerMenu` alone: a single-selection list with the current org checked. { - selectionMode: () => (model.isMobile ? "Single" : "None"), - selectedKeys: () => - model.isMobile && context.organizationId !== null - ? [`org:${context.organizationId}`] - : [], + selectionMode: () => (isMobile ? "Single" : "None"), + selectedKeys: () => (isMobile && context.organizationId !== null ? [context.organizationId] : []), }, ), }) } -const requested = (model: Model, outMessage: PageOutMessage): ShellReturn => ({ model, outMessage }) - -const foldUserMenuOutMessage = Menu.OutMessage.match< - Update.StepWithOutMessage<Model, Message, PageOutMessage> ->({ - SelectedItem: - ({ key }) => - (model) => - key === "status" - ? requested(model, PageOutMessage.RequestedModal({ modal: { _tag: "SetStatus" } })) - : key === "feedback" - ? requested(model, PageOutMessage.RequestedModal({ modal: { _tag: "Feedback" } })) - : key === "logout" - ? requested(model, PageOutMessage.RequestedSignOut()) - : { model }, - ActivatedLink: - ({ href }) => - (model) => - requested(model, PageOutMessage.RequestedNavigation({ href, replace: false })), -}) +const requested = + (outMessage: PageOutMessage): Step => + (model) => ({ model, outMessage }) + +const navigateTo = (route: AppRoute): Step => requested(PageOutMessage.RequestedNavigation({ route, replace: false })) + +const unchanged: Step = (model) => ({ model }) + +const foldUserMenuOutMessage = (context: Context) => + Menu.OutMessage.match<Step>({ + SelectedItem: ({ key }) => + Option.match(decodeUserMenuKey(key), { + onNone: () => unchanged, + onSome: (userKey) => + Match.value(userKey).pipe( + Match.withReturnType<Step>(), + Match.when("status", () => + requested(PageOutMessage.RequestedModal({ modal: { _tag: "SetStatus" } })), + ), + Match.when("feedback", () => + requested(PageOutMessage.RequestedModal({ modal: { _tag: "Feedback" } })), + ), + Match.when("logout", () => requested(PageOutMessage.RequestedSignOut())), + Match.orElse(() => unchanged), + ), + }), + ActivatedLink: ({ key }) => + Option.match( + Option.flatMap(decodeUserMenuKey(key), (userKey) => + context.orgSlug === null || context.currentUserId === null + ? Option.none() + : userMenuLinkRoute(userKey, context.orgSlug, context.currentUserId), + ), + { onNone: () => unchanged, onSome: navigateTo }, + ), + }) -const switcherModals: Readonly<Record<string, PageOutMessage>> = { - "invite-people": PageOutMessage.RequestedModal({ modal: { _tag: "EmailInvite" } }), - "create-channel": PageOutMessage.RequestedModal({ modal: { _tag: "NewChannel" } }), - "create-category": PageOutMessage.RequestedModal({ modal: { _tag: "CreateSection" } }), - "create-server": PageOutMessage.RequestedModal({ modal: { _tag: "CreateOrganization" } }), +const switcherModals: { readonly [Key in SwitcherKey]?: ModalRequest } = { + "invite-people": { _tag: "EmailInvite" }, + "create-channel": { _tag: "NewChannel" }, + "create-category": { _tag: "CreateSection" }, + "create-server": { _tag: "CreateOrganization" }, } +const decodeOrganizationId = Schema.decodeUnknownOption(OrganizationId) + /** * `SwitchServerMenu`: an org without a slug still has to finish setup (`getOrganizationRoute`). * Compared with the shown org, since a "Single" menu has already selected the clicked key. */ -const switchedOrganization = (model: Model, key: string, context: Context): ShellReturn => - Option.match( - Option.fromNullishOr( - model.userOrganizations.find( - (organization) => - `org:${organization.id}` === key && organization.id !== context.organizationId, - ), - ), - { - onNone: () => ({ model }), - onSome: (organization) => - requested( - model, - PageOutMessage.RequestedNavigation({ - href: organizationHref(organization), - replace: false, - }), +const switchedOrganization = + (organizationId: OrganizationId, context: Context): Step => + (model) => + Option.match( + Option.fromNullishOr( + model.userOrganizations.find( + (organization) => organization.id === organizationId && organization.id !== context.organizationId, ), - }, - ) + ), + { + onNone: () => ({ model }), + onSome: (organization) => navigateTo(organizationRoute(organization))(model), + }, + ) const foldOrgSwitcherOutMessage = (context: Context) => - Menu.OutMessage.match<Update.StepWithOutMessage<Model, Message, PageOutMessage>>({ - SelectedItem: - ({ key }) => - (model) => { - const modal = switcherModals[key] - return modal ? requested(model, modal) : switchedOrganization(model, key, context) - }, - ActivatedLink: - ({ href }) => - (model) => - requested(model, PageOutMessage.RequestedNavigation({ href, replace: false })), + Menu.OutMessage.match<Step>({ + SelectedItem: ({ key }) => + Option.match(decodeSwitcherKey(key), { + onSome: (switcherKey) => + Option.match(Option.fromNullishOr(switcherModals[switcherKey]), { + onNone: () => unchanged, + onSome: (modal) => requested(PageOutMessage.RequestedModal({ modal })), + }), + onNone: () => + Option.match(decodeOrganizationId(key), { + onNone: () => unchanged, + onSome: (organizationId) => switchedOrganization(organizationId, context), + }), + }), + ActivatedLink: ({ key }) => + Option.match( + Option.flatMap(decodeSwitcherKey(key), (switcherKey) => + context.orgSlug === null ? Option.none() : switcherLinkRoute(switcherKey, context.orgSlug), + ), + { onNone: () => unchanged, onSome: navigateTo }, + ), }) -const foldUserMenu = Update.foldChild({ - update: Menu.update, - read: (model: Model) => Option.some(model.userMenu), - write: (model, userMenu) => modifyFields(model, { userMenu: () => userMenu }), - toParentMessage: (message) => Message.GotUserMenuMessage({ message }), - foldOutMessage: foldUserMenuOutMessage, -}) +const foldUserMenu = (context: Context) => + Update.foldChild({ + update: Menu.update, + read: (model: Model) => Option.some(model.userMenu), + write: (model, userMenu) => modifyFields(model, { userMenu: () => userMenu }), + toParentMessage: (message) => Message.GotUserMenuMessage({ message }), + foldOutMessage: foldUserMenuOutMessage(context), + }) const foldOrgSwitcher = (context: Context) => Update.foldChild({ @@ -175,65 +195,51 @@ const foldOrgSwitcher = (context: Context) => }) /** The sidebar's Commands, plus its OutMessage (modals, navigation, toasts) for the root. */ -const foldChannelsSidebar = ( - model: Model, - step: (sidebar: ChannelsSidebar.Model) => ChannelsSidebar.SidebarReturn, -): ShellUpdateReturn => { - const result = step(model.channelsSidebar) - return { - model: modifyFields(model, { channelsSidebar: () => result.model }), - commands: Command.mapMessages(result.commands ?? [], (message) => - Message.GotChannelsSidebarMessage({ message }), - ), - ...(result.outMessage === undefined ? {} : { outMessage: result.outMessage }), - } -} +const foldChannelsSidebar = Update.foldChild({ + update: ChannelsSidebar.update, + read: (model: Model) => Option.some(model.channelsSidebar), + write: (model, channelsSidebar) => modifyFields(model, { channelsSidebar: () => channelsSidebar }), + toParentMessage: (message) => Message.GotChannelsSidebarMessage({ message }), + toParentOutMessage: (outMessage: PageOutMessage) => outMessage, +}) + +const foldNotifications = Update.foldChild({ + update: Notifications.update, + read: (model: Model) => Option.some(model.notifications), + write: (model, notifications) => modifyFields(model, { notifications: () => notifications }), + toParentMessage: (message) => Message.GotNotificationsMessage({ message }), +}) -export const update = (model: Model, message: Message, context: Context): ShellUpdateReturn => { - const result = Message.match<ShellUpdateReturn>(message, { - GotChannelsSidebarMessage: ({ message }) => - foldChannelsSidebar(model, (sidebar) => ChannelsSidebar.update(sidebar, message)), - GotUserMenuMessage: ({ message }) => foldUserMenu(model, message), +const foldMobileSidebar = Update.foldChild({ + update: Modal.update, + read: (model: Model) => Option.some({ id: MOBILE_SIDEBAR_ID, isOpen: model.isSidebarOpen }), + write: (model, sheet) => modifyFields(model, { isSidebarOpen: () => sheet.isOpen }), + toParentMessage: (message) => Message.GotMobileSidebarMessage({ message }), +}) + +export const update = (model: Model, message: Message, context: Context): ShellReturn => + Message.match<ShellReturn>(message, { + GotChannelsSidebarMessage: ({ message }) => foldChannelsSidebar(model, message), + GotUserMenuMessage: ({ message }) => foldUserMenu(context)(model, message), GotOrgSwitcherMessage: ({ message }) => foldOrgSwitcher(context)(model, message), UpdatedUserOrganizations: ({ organizations }) => ({ - model: modifyFields(model, { userOrganizations: () => organizations }), + model: withMenuEntries(modifyFields(model, { userOrganizations: () => organizations }), context), }), UpdatedUserStatus: ({ status }) => ({ model: modifyFields(model, { userStatus: () => status }) }), - GotNotificationsMessage: ({ message }) => { - const result = Notifications.update(model.notifications, message) - return { - model: modifyFields(model, { notifications: () => result.model }), - commands: Command.mapMessages(result.commands ?? [], (child) => - Message.GotNotificationsMessage({ message: child }), - ), - } - }, + GotNotificationsMessage: ({ message }) => foldNotifications(model, message), UpdatedSettingsChannel: ({ channel }) => ({ model: modifyFields(model, { settingsChannel: () => channel }), }), ToggledSidebar: ({ isOpen }) => ({ model: modifyFields(model, { isSidebarOpen: () => isOpen }) }), - ChangedViewport: ({ isMobile }) => ({ model: modifyFields(model, { isMobile: () => isMobile }) }), - GotMobileSidebarMessage: ({ message }) => { - const sheet = Modal.update({ id: MOBILE_SIDEBAR_ID, isOpen: model.isSidebarOpen }, message) - return { - model: modifyFields(model, { isSidebarOpen: () => sheet.model.isOpen }), - commands: Command.mapMessages(sheet.commands ?? [], (child) => - Message.GotMobileSidebarMessage({ message: child }), - ), - } - }, + GotMobileSidebarMessage: ({ message }) => foldMobileSidebar(model, message), }) - return { ...result, model: withMenuEntries(result.model, context) } -} -/** The root learned a new route, organization, user or role. */ -export const informContext = (model: Model, context: Context): Update.Return<Model, Message, HazelRpc> => { - // `setContext` only resets data, so there is no OutMessage to forward. - const { model: next, commands } = foldChannelsSidebar(withMenuEntries(model, context), (sidebar) => - ChannelsSidebar.setContext(sidebar, { - organizationId: context.organizationId, - currentUserId: context.currentUserId, - }), - ) - return { model: next, commands } +/** The root learned a new route, organization, user, role or viewport. */ +export const informContext = (model: Model, context: Context): Model => { + // `setContext` only drops the previous organization's data: no Commands, no OutMessage. + const sidebar = ChannelsSidebar.setContext(model.channelsSidebar, { + organizationId: context.organizationId, + currentUserId: context.currentUserId, + }) + return withMenuEntries(modifyFields(model, { channelsSidebar: () => sidebar.model }), context) } diff --git a/apps/web-foldkit/src/test/root-fixtures.ts b/apps/web-foldkit/src/test/root-fixtures.ts index b346bdd88..4fe64ce0f 100644 --- a/apps/web-foldkit/src/test/root-fixtures.ts +++ b/apps/web-foldkit/src/test/root-fixtures.ts @@ -47,6 +47,7 @@ export const urlOf = (path: string): Url => export interface BootOptions { readonly themePreference?: ThemePreference readonly systemTheme?: "light" | "dark" + readonly isMobile?: boolean } /** `init` at `path` with default flags (the Model and its Commands). */ @@ -57,6 +58,8 @@ export const bootReturn = (path: string, options: BootOptions = {}) => systemTheme: options.systemTheme ?? "light", soundSettings: DEFAULT_SOUND_SETTINGS, sessionStartMs: 0, + isMobile: options.isMobile ?? false, + isCreateChannelHintDismissed: false, }, urlOf(path), ) @@ -72,7 +75,7 @@ export const sessionMessages = (member: Member | null = owner): ReadonlyArray<Me Message.ChangedAuth({ auth: "SignedIn" }), Message.SucceededFetchCurrentUser({ user: ada }), Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazelOrg }), - Message.UpdatedMember({ member }), + Message.UpdatedMember({ organizationId: hazelOrg.id, member }), ] /** The app at `path` once the session has loaded (the org shell renders). */ diff --git a/apps/web-foldkit/src/theme.test.ts b/apps/web-foldkit/src/theme.test.ts index 01e29f498..6982dcf82 100644 --- a/apps/web-foldkit/src/theme.test.ts +++ b/apps/web-foldkit/src/theme.test.ts @@ -49,7 +49,17 @@ describe("stored theme preference", () => { const boot = (themePreference = defaultThemePreference()) => Option.match(fromString("http://localhost/sign-in"), { onNone: () => expect.unreachable("url"), - onSome: (url) => init({ themePreference, systemTheme: "light", soundSettings: DEFAULT_SOUND_SETTINGS, sessionStartMs: 0 }, url), + onSome: (url) => init( + { + themePreference, + systemTheme: "light", + soundSettings: DEFAULT_SOUND_SETTINGS, + sessionStartMs: 0, + isMobile: false, + isCreateChannelHintDismissed: false, + }, + url, + ), }) describe("root theme", () => { From 7dcf558faf78c26bbdf169b61ac573de9ba3e3ac Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:27:06 +0200 Subject: [PATCH 36/52] fix(web): modal results route by instance; delete-message failure toasts Every modal opening gets an instance number carried by its Slot and its Messages, so a late result from a closed or replaced instance still delivers its toast but never closes the open one. DeleteMessage emits Succeeded/FailedDeleteMessage({ reason }). The palette search update is an exhaustive match, its editor goes through the EditorRegistry, delete-channel no longer reads window.location, recent channels use Clock, and the frame drops its always-true isOpen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/app/out-messages.story.test.ts | 8 +- apps/web-foldkit/src/main.ts | 12 +- .../src/overlay/command-palette/commands.ts | 42 ++--- .../src/overlay/command-palette/message.ts | 30 +++- .../command-palette/palette.story.test.ts | 73 ++++++++- .../overlay/command-palette/search-editor.ts | 12 +- .../overlay/command-palette/search-mount.ts | 119 ++++++++------ .../overlay/command-palette/search-update.ts | 128 +++++++++------ .../src/overlay/command-palette/update.ts | 44 +++-- .../src/overlay/modal.story.test.ts | 141 ++++++++++++++++ apps/web-foldkit/src/overlay/modal.ts | 150 +++++++++++++----- .../src/overlay/modal/change-role.ts | 6 +- .../web-foldkit/src/overlay/modal/contract.ts | 76 +++++---- .../src/overlay/modal/create-bot.ts | 4 +- .../src/overlay/modal/create-dm.ts | 9 +- .../src/overlay/modal/create-organization.ts | 4 +- .../src/overlay/modal/create-section.ts | 4 +- .../src/overlay/modal/delete-bot.ts | 4 +- .../src/overlay/modal/delete-channel.ts | 98 ++++++------ .../src/overlay/modal/delete-message.ts | 48 ++++-- .../src/overlay/modal/delete-workspace.ts | 4 +- .../web-foldkit/src/overlay/modal/edit-bot.ts | 4 +- .../src/overlay/modal/email-invite.ts | 4 +- .../web-foldkit/src/overlay/modal/feedback.ts | 4 +- apps/web-foldkit/src/overlay/modal/frame.ts | 33 +++- .../src/overlay/modal/install-bot-by-id.ts | 4 +- .../src/overlay/modal/join-channel.ts | 6 +- .../src/overlay/modal/new-channel.ts | 4 +- .../src/overlay/modal/regenerate-bot-token.ts | 4 +- .../src/overlay/modal/rename-channel.ts | 67 +++----- .../src/overlay/modal/rename-thread.ts | 1 - .../src/overlay/modal/request-integration.ts | 4 +- .../src/overlay/modal/set-status.ts | 4 +- 33 files changed, 767 insertions(+), 388 deletions(-) create mode 100644 apps/web-foldkit/src/overlay/modal.story.test.ts 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..1536f0ed2 100644 --- a/apps/web-foldkit/src/app/out-messages.story.test.ts +++ b/apps/web-foldkit/src/app/out-messages.story.test.ts @@ -54,7 +54,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 +67,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") }), ) }) @@ -179,13 +179,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")), ) }) diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index 33b26df08..d4c1e9848 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -109,6 +109,8 @@ const followModalOutMessage = (model: Model, outMessage: Option.Option<ModalOutM }), }) +const modalContextOf = (model: Model): Modal.ModalContext => ({ ...sharedOf(model), route: model.route }) + /** The modal slot after an open or update; a closing OutMessage already dropped the slot. */ const withModal = (model: Model, transition: Modal.Transition): Return => { const followed = followModalOutMessage(modifyFields(model, { modal: () => transition.model }), transition.outMessage) @@ -133,7 +135,7 @@ const withCommandPalette = (model: Model, result: CommandPaletteReturn): Return onNone: (): Return => ({ model: next }), onSome: CommandPalette.OutMessage.match<Return>({ Completed: ({ href, toast }) => completedOverlay(next, href, toast), - RequestedModal: ({ modal }) => withModal(next, Modal.open(next.modal, modal, sharedOf(next))), + RequestedModal: ({ modal }) => withModal(next, Modal.open(next.modal, modal, modalContextOf(next))), RequestedToast: ({ toast }) => withToasts(next, Toasts.push(next.toasts, toast)), RequestedTheme: ({ preference }) => requestTheme(next, preference), RequestedPresenceStatus: ({ status }) => withPlatform(next, Platform.pickPresenceStatus(next.platform, status)), @@ -145,7 +147,7 @@ const withCommandPalette = (model: Model, result: CommandPaletteReturn): Return /** `useAppHotkey` handlers in `$orgSlug/layout.tsx`. */ const pressedHotkey = (model: Model, actionId: LayoutHotkeyActionId): Return => { const shared = sharedOf(model) - const openModal = (modal: Modal.ModalRequest) => withModal(model, Modal.open(model.modal, modal, shared)) + const openModal = (modal: Modal.ModalRequest) => withModal(model, Modal.open(model.modal, modal, modalContextOf(model))) // Legacy passes `initialPage`, but the palette shows its own (reset) page: always home. const openPalette = () => withCommandPalette(model, CommandPalette.open(model.commandPalette, "home", shared)) return Match.value(actionId).pipe( @@ -273,7 +275,7 @@ const handleOutMessage = (outMessage: PageOutMessage): Step => RequestedModal: ({ modal }) => (model) => - withModal(model, Modal.open(model.modal, modal, sharedOf(model))), + withModal(model, Modal.open(model.modal, modal, modalContextOf(model))), RequestedCommandPalette: ({ page }) => (model) => @@ -409,7 +411,7 @@ export const init: Runtime.RoutingApplicationInit<Model, Message, Flags, Resourc sessionStartMs: flags.sessionStartMs, page: null, shell: Shell.init(), - modal: null, + modal: Modal.initialModel, commandPalette: CommandPalette.init(), toasts: Toasts.init(), platform: Platform.init(), @@ -536,7 +538,7 @@ export const update = (model: Model, message: Message): Return => informPage, ]) }, - GotModalMessage: ({ message }) => withModal(model, Modal.update(model.modal, message, sharedOf(model))), + GotModalMessage: ({ message }) => withModal(model, Modal.update(model.modal, message, modalContextOf(model))), GotCommandPaletteMessage: ({ message }) => withCommandPalette(model, CommandPalette.update(model.commandPalette, message, sharedOf(model))), PressedHotkey: ({ actionId }) => pressedHotkey(model, actionId), diff --git a/apps/web-foldkit/src/overlay/command-palette/commands.ts b/apps/web-foldkit/src/overlay/command-palette/commands.ts index 32cf30244..11481b0c4 100644 --- a/apps/web-foldkit/src/overlay/command-palette/commands.ts +++ b/apps/web-foldkit/src/overlay/command-palette/commands.ts @@ -1,13 +1,13 @@ import { ChannelId, OrganizationId, UserId } from "@hazel/schema" -import { Cause, Effect, Option, Schema } from "effect" +import { Clock, Effect, Option, Schema } from "effect" import { Command } from "foldkit" import * as Dom from "foldkit/dom" import { createChannelAction, joinChannelAction } from "~/db/actions" +import { catchCommon } from "../../data/actions" import { PresenceStatus } from "../../platform/presence/model" import { HazelRpc } from "../../rpc" import { Message } from "./message" import { ChannelType } from "./model" -import { failureToast } from "../../data/actions" /** Side effects of the palette pages, each the call legacy makes from the same handler. */ @@ -25,7 +25,7 @@ export const CreateChannel = Command.define("CreateChannel", { currentUserId, }).pipe( Effect.map((result) => Message.SucceededCreateChannel({ channelId: result.data.channelId })), - Effect.catchCause((cause) => Effect.succeed(Message.FailedCreateChannel({ toast: failureToast(cause, "friendly") }))), + catchCommon((reason) => Message.FailedCreateChannel({ reason })), ), }) @@ -36,19 +36,10 @@ export const JoinChannel = Command.define("JoinChannel", { execute: ({ channelId, userId }) => joinChannelAction({ channelId, userId }).pipe( Effect.as(Message.SucceededJoinChannel()), - Effect.catchCause((cause) => - Effect.succeed( - Message.FailedJoinChannel({ - toast: failureToast(cause, "friendly", { - ChannelNotFoundError: { - title: "Channel not found", - description: "This channel may have been deleted.", - isRetryable: false, - }, - }), - }), - ), - ), + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(Message.FailedJoinChannel({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedJoinChannel({ reason })), ), }) @@ -61,11 +52,7 @@ export const SetPresenceStatus = Command.define("SetPresenceStatus", { const client = yield* HazelRpc yield* client("userPresenceStatus.update", { status, customMessage: null }) return Message.SucceededSetPresenceStatus() - }).pipe( - Effect.catchCause((cause) => - Effect.succeed(Message.FailedSetPresenceStatus({ reason: Cause.pretty(cause) })), - ), - ), + }).pipe(catchCommon((reason) => Message.FailedSetPresenceStatus({ reason }))), }) const RECENT_CHANNELS_KEY = "recentChannels" @@ -85,13 +72,12 @@ export const TrackRecentChannel = Command.define("TrackRecentChannel", { args: { channelId: ChannelId }, messages: [Message.CompletedTrackRecentChannel], execute: ({ channelId }) => - readRecentChannels.pipe( - Effect.map((channels) => - [{ channelId, visitedAt: Date.now() }, ...channels.filter((entry) => entry.channelId !== channelId)].slice( - 0, - MAX_RECENT_CHANNELS, - ), + Effect.zipWith(readRecentChannels, Clock.currentTimeMillis, (channels, visitedAt) => + [{ channelId, visitedAt }, ...channels.filter((entry) => entry.channelId !== channelId)].slice( + 0, + MAX_RECENT_CHANNELS, ), + ).pipe( // Best effort: a full or blocked localStorage only loses the recent list. Effect.flatMap((channels) => Effect.try(() => localStorage.setItem(RECENT_CHANNELS_KEY, JSON.stringify(channels)))), Effect.ignore, @@ -110,7 +96,7 @@ export const readRecentChannelIds = readRecentChannels.pipe( ), ) -/** `autoFocus` on a form page's input, after the page renders. */ +/** `autoFocus` on a form page's input, after the page renders. Best effort: a missing input just stays unfocused. */ export const FocusInput = Command.define("FocusInput", { args: { selector: Schema.String }, messages: [Message.CompletedFocusInput], diff --git a/apps/web-foldkit/src/overlay/command-palette/message.ts b/apps/web-foldkit/src/overlay/command-palette/message.ts index 8fadf197b..cb3ad7143 100644 --- a/apps/web-foldkit/src/overlay/command-palette/message.ts +++ b/apps/web-foldkit/src/overlay/command-palette/message.ts @@ -1,6 +1,7 @@ import { ChannelId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" import * as CommandMenu from "../../ui/command-menu" import { ModalRequest } from "../modal" import { ToastRequest } from "../toasts" @@ -9,6 +10,13 @@ import { ThemePreference } from "../../theme" import { ChannelSummary, ChannelType, DmChannel } from "./model" import { RecentSearch, SearchAutocomplete, SearchData, Suggestion } from "./search-data" +/** What can go wrong joining a channel, beyond the shared reasons. */ +export const JoinChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type JoinChannelFailure = typeof JoinChannelFailure.Type + +/** The search page's editor is gone (the page closed before the Command ran). */ +export const SearchEditorFailure = Schema.Literals(["EditorNotMounted"]) + export const Message = defineMessageUnion({ GotMenuMessage: { message: CommandMenu.Message }, ClickedBack: {}, @@ -24,11 +32,11 @@ export const Message = defineMessageUnion({ ChangedChannelType: { value: ChannelType }, SubmittedCreateChannel: {}, SucceededCreateChannel: { channelId: ChannelId }, - FailedCreateChannel: { toast: ToastRequest }, + FailedCreateChannel: { reason: CommonReason }, ChangedJoinSearch: { value: Schema.String }, ClickedJoinChannel: { channelId: ChannelId }, SucceededJoinChannel: {}, - FailedJoinChannel: { toast: ToastRequest }, + FailedJoinChannel: { reason: JoinChannelFailure }, // Search page EditedSearch: { text: Schema.String, autocomplete: Schema.NullOr(SearchAutocomplete) }, PressedSearchKey: { key: Schema.Literals(["ArrowDown", "ArrowUp", "Enter", "BackspaceAtStart"]) }, @@ -47,12 +55,18 @@ export const Message = defineMessageUnion({ CompletedTrackRecentChannel: {}, SucceededSetPresenceStatus: {}, /** Legacy ignores the exit of `setStatus`, so a failure shows nothing. */ - FailedSetPresenceStatus: { reason: Schema.String }, - CompletedSetSearchText: {}, - CompletedDeleteFilterText: {}, - CompletedSyncSuggestionCount: {}, - CompletedSetSearchPlaceholder: {}, - CompletedFocusSearchEditor: {}, + FailedSetPresenceStatus: { reason: CommonReason }, + // The search editor's Commands: each fails only when the editor is not mounted. + SucceededSetSearchText: {}, + FailedSetSearchText: { reason: SearchEditorFailure }, + SucceededDeleteFilterText: {}, + FailedDeleteFilterText: { reason: SearchEditorFailure }, + SucceededSyncSuggestionCount: {}, + FailedSyncSuggestionCount: { reason: SearchEditorFailure }, + SucceededSetSearchPlaceholder: {}, + FailedSetSearchPlaceholder: { reason: SearchEditorFailure }, + SucceededFocusSearchEditor: {}, + FailedFocusSearchEditor: { reason: SearchEditorFailure }, CompletedSaveRecentSearches: {}, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/overlay/command-palette/palette.story.test.ts b/apps/web-foldkit/src/overlay/command-palette/palette.story.test.ts index 6a25770d2..555bc7f69 100644 --- a/apps/web-foldkit/src/overlay/command-palette/palette.story.test.ts +++ b/apps/web-foldkit/src/overlay/command-palette/palette.story.test.ts @@ -8,9 +8,15 @@ import * as CommandMenu from "../../ui/command-menu" import { CreateChannel, FocusInput, JoinChannel, SetPresenceStatus, TrackRecentChannel } from "./commands" import { Message, OutMessage } from "./message" import type { Model } from "./model" -import { SaveRecentSearches } from "./search-mount" +import { + FocusSearchEditor, + LoadRecentSearches, + SaveRecentSearches, + SetSearchPlaceholder, + SetSearchText, +} from "./search-mount" import { CREATE_CHANNEL_INPUT_ID, init, JOIN_CHANNEL_INPUT_ID, MENU_ID, open, update } from "./update" -import { successToast } from "../../data/actions" +import { reasonToast, successToast } from "../../data/actions" /** The palette's pages, back stack and actions (legacy `components/command-palette`). */ @@ -140,7 +146,6 @@ describe("create channel", () => { }) test("submits once while the action runs, and a failure toasts and re-enables", () => { - const failed = { intent: "error" as const, title: "Nope", description: null } story( run, given(onCreate), @@ -156,8 +161,8 @@ describe("create channel", () => { }), ), model((m) => expect(m.page).toMatchObject({ isSubmitting: true })), - Command.resolve(CreateChannel, Message.FailedCreateChannel({ toast: failed })), - expectOutMessage(OutMessage.RequestedToast({ toast: failed })), + Command.resolve(CreateChannel, Message.FailedCreateChannel({ reason: "Offline" })), + expectOutMessage(OutMessage.RequestedToast({ toast: reasonToast("Offline") })), model((m) => { expect(m.isOpen).toBe(true) expect(m.page).toMatchObject({ isSubmitting: false, name: "design" }) @@ -244,7 +249,7 @@ describe("home actions", () => { run, given(opened), message(item("status:dnd")), - Command.resolve(SetPresenceStatus, Message.FailedSetPresenceStatus({ reason: "offline" })), + Command.resolve(SetPresenceStatus, Message.FailedSetPresenceStatus({ reason: "Offline" })), model((m) => expect(m.isOpen).toBe(false)), ) }) @@ -292,4 +297,60 @@ describe("search page", () => { model((m) => expect(m.recentSearches).toEqual(recent)), ) }) + + const recentSearches = [{ query: "older", filters: [], timestamp: 1 }] + const onSearch: Model = { + ...opened, + page: { _tag: "Search", query: "", rawInput: "", filters: [], selectedIndex: 0 }, + recentSearches, + } + + test("opening the search page loads recent searches and focuses the editor", () => { + story( + run, + given(opened), + message(item("action:search")), + Command.expectExact(LoadRecentSearches(), FocusSearchEditor()), + Command.resolve(LoadRecentSearches, Message.LoadedRecentSearches({ searches: recentSearches })), + Command.resolve(FocusSearchEditor, Message.FailedFocusSearchEditor({ reason: "EditorNotMounted" })), + model((m) => { + expect(m.page._tag).toBe("Search") + expect(m.recentSearches).toEqual(recentSearches) + }), + ) + }) + + // P0: the old `if` chain fell through to "clear recent searches" for any message it did not list. + test.each([ + Message.HoveredSuggestion({ index: 0 }), + Message.UpdatedSuggestions({ suggestions: [] }), + Message.PressedAutocompleteKey({ key: "Escape" }), + Message.EditedSearch({ text: "hi", autocomplete: null }), + ])("$_tag keeps the recent searches", (searchMessage) => { + const result = run(onSearch, searchMessage) + expect(result.model.recentSearches).toEqual(recentSearches) + expect(result.commands?.map((command) => command.name) ?? []).not.toContain("SaveRecentSearches") + }) + + test("only Clear empties and saves the recent searches", () => { + story( + run, + given(onSearch), + message(Message.ClickedClearRecentSearches()), + Command.expectExact(SaveRecentSearches({ searches: [] })), + Command.resolve(SaveRecentSearches, Message.CompletedSaveRecentSearches()), + model((m) => expect(m.recentSearches).toEqual([])), + ) + }) + + test("an editor Command after the page closed fails without touching the Model", () => { + story( + run, + given(onSearch), + message(Message.ClickedClearSearch()), + Command.resolve(SetSearchText, Message.FailedSetSearchText({ reason: "EditorNotMounted" })), + Command.resolve(SetSearchPlaceholder, Message.SucceededSetSearchPlaceholder()), + model((m) => expect(m.page).toMatchObject({ _tag: "Search", rawInput: "", query: "" })), + ) + }) }) diff --git a/apps/web-foldkit/src/overlay/command-palette/search-editor.ts b/apps/web-foldkit/src/overlay/command-palette/search-editor.ts index 01ab066d7..d8a9014d8 100644 --- a/apps/web-foldkit/src/overlay/command-palette/search-editor.ts +++ b/apps/web-foldkit/src/overlay/command-palette/search-editor.ts @@ -14,7 +14,7 @@ const schema = new ProseMirrorSchema({ paragraph: { content: "text*", marks: "", - toDOM: () => ["div", { "data-slate-node": "element", style: "position: relative;" }, 0], + toDOM: () => ["div", { style: "position: relative;" }, 0], }, text: {}, }, @@ -156,9 +156,6 @@ export const createSearchEditor = ( class: EDITABLE_CLASS, role: "textbox", "aria-multiline": "true", - "data-slate-editor": "true", - "data-slate-node": "value", - zindex: "-1", style: "position: relative; white-space: pre-wrap; overflow-wrap: break-word;", }, dispatchTransaction: (tr) => { @@ -187,10 +184,3 @@ export const deleteFilterText = (view: EditorView, filterStartOffset: number) => export const syncOptionCount = (view: EditorView, count: number) => { if ((optionCountKey.getState(view.state) ?? 0) !== count) view.dispatch(view.state.tr.setMeta(optionCountKey, count)) } - -const editors = new Map<string, EditorView>() -export const registerSearchEditor = (id: string, view: EditorView) => editors.set(id, view) -export const unregisterSearchEditor = (id: string, view: EditorView) => { - if (editors.get(id) === view) editors.delete(id) -} -export const searchEditorById = (id: string) => editors.get(id) diff --git a/apps/web-foldkit/src/overlay/command-palette/search-mount.ts b/apps/web-foldkit/src/overlay/command-palette/search-mount.ts index 6c07c069b..ac2715951 100644 --- a/apps/web-foldkit/src/overlay/command-palette/search-mount.ts +++ b/apps/web-foldkit/src/overlay/command-palette/search-mount.ts @@ -1,17 +1,16 @@ import { Effect, Match, Option, Queue, Schema, Stream } from "effect" -import { Command, Mount } from "foldkit" +import { Command, Mount, Render } from "foldkit" +import { readStored, writeStored } from "../../data/storage" +import { type EditorNotMounted, editorRegistry, withEditor } from "../../editor/registry" import { Message } from "./message" import { RecentSearch } from "./search-data" import { createSearchEditor, deleteFilterText, - registerSearchEditor, type SearchEditorEvent, - searchEditorById, setPlaceholder, setText, syncOptionCount, - unregisterSearchEditor, } from "./search-editor" /** The search page's editor Mount and the Commands that reach the live editor. */ @@ -32,91 +31,115 @@ const toMessage = (event: SearchEditorEvent): EditorMessage => }), ) -/** Owns the ProseMirror editor while the search page shows; focuses it on mount like legacy. */ +/** + * Owns the ProseMirror editor while the search page shows, registered in the `EditorRegistry` for the + * Commands below. Focus is `FocusSearchEditor`'s job, sent when the page opens. + */ export const MountSearchEditor = Mount.defineStream("MountSearchEditor", { args: { placeholder: Schema.String }, messages: [Message.EditedSearch, Message.PressedSearchKey, Message.PressedAutocompleteKey], - execute: ({ element, placeholder }) => + execute: ({ element, placeholder, viewStateChanges }) => // The keyed host is always a div; anything else has no editor to mount. !(element instanceof HTMLElement) ? Stream.empty : Stream.callback<EditorMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const view = createSearchEditor(element, { - placeholder, - emit: (event) => Queue.offerUnsafe(queue, toMessage(event)), - }) - registerSearchEditor(SEARCH_EDITOR_ID, view) - view.focus() - return view - }), - (view) => - Effect.sync(() => { - unregisterSearchEditor(SEARCH_EDITOR_ID, view) - view.destroy() - }), - ).pipe(Effect.flatMap(() => Effect.never)), + Effect.gen(function* () { + const view = yield* Effect.acquireRelease( + Effect.sync(() => + createSearchEditor(element, { + placeholder, + emit: (event) => Queue.offerUnsafe(queue, toMessage(event)), + }), + ), + (editor) => Effect.sync(() => editor.destroy()), + ) + yield* editorRegistry.register(SEARCH_EDITOR_ID, view) + // Read-only while DevTools shows a historical view. + yield* viewStateChanges.pipe( + Stream.runForEach((viewState) => + Effect.sync(() => view.setProps({ editable: () => viewState === "Live" })), + ), + Effect.forkScoped, + ) + return yield* Effect.never + }), ), }) -const withEditor = <A>(f: (view: NonNullable<ReturnType<typeof searchEditorById>>) => void, completed: A) => - Effect.sync(() => { - const view = searchEditorById(SEARCH_EDITOR_ID) - if (view) f(view) - }).pipe(Effect.as(completed)) +/** Every editor Command fails the same way: the search page (and so its editor) is gone. */ +const editorFailed = + <M>(toFailure: (failure: { reason: "EditorNotMounted" }) => M) => + <A, R>(effect: Effect.Effect<A, EditorNotMounted, R>): Effect.Effect<A | M, never, R> => + effect.pipe( + Effect.catchTags({ EditorNotMounted: () => Effect.succeed(toFailure({ reason: "EditorNotMounted" })) }), + ) export const SetSearchText = Command.define("SetSearchText", { args: { text: Schema.String }, - messages: [Message.CompletedSetSearchText], - execute: ({ text }) => withEditor((view) => setText(view, text), Message.CompletedSetSearchText()), + messages: [Message.SucceededSetSearchText, Message.FailedSetSearchText], + execute: ({ text }) => + withEditor(SEARCH_EDITOR_ID, (view) => setText(view, text)).pipe( + Effect.as(Message.SucceededSetSearchText()), + editorFailed(Message.FailedSetSearchText), + ), }) export const DeleteFilterText = Command.define("DeleteFilterText", { args: { filterStartOffset: Schema.Number }, - messages: [Message.CompletedDeleteFilterText], + messages: [Message.SucceededDeleteFilterText, Message.FailedDeleteFilterText], execute: ({ filterStartOffset }) => - withEditor((view) => deleteFilterText(view, filterStartOffset), Message.CompletedDeleteFilterText()), + withEditor(SEARCH_EDITOR_ID, (view) => deleteFilterText(view, filterStartOffset)).pipe( + Effect.as(Message.SucceededDeleteFilterText()), + editorFailed(Message.FailedDeleteFilterText), + ), }) export const SyncSuggestionCount = Command.define("SyncSuggestionCount", { args: { count: Schema.Number }, - messages: [Message.CompletedSyncSuggestionCount], + messages: [Message.SucceededSyncSuggestionCount, Message.FailedSyncSuggestionCount], execute: ({ count }) => - withEditor((view) => syncOptionCount(view, count), Message.CompletedSyncSuggestionCount()), + withEditor(SEARCH_EDITOR_ID, (view) => syncOptionCount(view, count)).pipe( + Effect.as(Message.SucceededSyncSuggestionCount()), + editorFailed(Message.FailedSyncSuggestionCount), + ), }) export const SetSearchPlaceholder = Command.define("SetSearchPlaceholder", { args: { placeholder: Schema.String }, - messages: [Message.CompletedSetSearchPlaceholder], + messages: [Message.SucceededSetSearchPlaceholder, Message.FailedSetSearchPlaceholder], execute: ({ placeholder }) => - withEditor((view) => setPlaceholder(view, placeholder), Message.CompletedSetSearchPlaceholder()), + withEditor(SEARCH_EDITOR_ID, (view) => setPlaceholder(view, placeholder)).pipe( + Effect.as(Message.SucceededSetSearchPlaceholder()), + editorFailed(Message.FailedSetSearchPlaceholder), + ), }) +/** Legacy `autoFocus`: once the render that shows the search page (and mounts its editor) commits. */ export const FocusSearchEditor = Command.define("FocusSearchEditor", { - messages: [Message.CompletedFocusSearchEditor], - execute: withEditor((view) => view.focus(), Message.CompletedFocusSearchEditor()), + messages: [Message.SucceededFocusSearchEditor, Message.FailedFocusSearchEditor], + execute: Render.afterCommit.pipe( + Effect.andThen(withEditor(SEARCH_EDITOR_ID, (view) => view.focus())), + Effect.as(Message.SucceededFocusSearchEditor()), + editorFailed(Message.FailedFocusSearchEditor), + ), }) +/** `recentSearchesAtom` (platform storage, JSON). */ const RECENT_SEARCHES_KEY = "recentSearches" -const decodeRecent = Schema.decodeUnknownOption(Schema.Array(RecentSearch)) +const RecentSearches = Schema.Array(RecentSearch) -/** `recentSearchesAtom` (platform storage, JSON). */ +/** Missing or unreadable storage is no recent searches. */ export const LoadRecentSearches = Command.define("LoadRecentSearches", { messages: [Message.LoadedRecentSearches], - execute: Effect.try(() => JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY) ?? "[]")).pipe( - Effect.map((raw) => Option.getOrElse(decodeRecent(raw), () => [])), - Effect.orElseSucceed(() => []), - Effect.map((searches) => Message.LoadedRecentSearches({ searches })), + execute: readStored(RECENT_SEARCHES_KEY, RecentSearches).pipe( + Effect.map((stored) => Message.LoadedRecentSearches({ searches: Option.getOrElse(stored, () => []) })), ), }) +/** Best effort, like the legacy atom: a full or blocked store only loses the history. */ export const SaveRecentSearches = Command.define("SaveRecentSearches", { - args: { searches: Schema.Array(RecentSearch) }, + args: { searches: RecentSearches }, messages: [Message.CompletedSaveRecentSearches], execute: ({ searches }) => - Effect.try(() => localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(searches))).pipe( - Effect.ignoreCause, - Effect.as(Message.CompletedSaveRecentSearches()), - ), + writeStored(RECENT_SEARCHES_KEY, RecentSearches, searches).pipe(Effect.as(Message.CompletedSaveRecentSearches())), }) diff --git a/apps/web-foldkit/src/overlay/command-palette/search-update.ts b/apps/web-foldkit/src/overlay/command-palette/search-update.ts index 0af4e9640..768819b15 100644 --- a/apps/web-foldkit/src/overlay/command-palette/search-update.ts +++ b/apps/web-foldkit/src/overlay/command-palette/search-update.ts @@ -1,3 +1,4 @@ +import { Match } from "effect" import { modifyFields } from "foldkit/struct" import { parseSearchInput } from "~/lib/search-filter-parser" import type { Shared } from "../../page/contract" @@ -35,6 +36,7 @@ type SearchMessage = Extract< } > type SearchPage = Extract<PageState, { _tag: "Search" }> +type SearchKey = Extract<SearchMessage, { _tag: "PressedSearchKey" }>["key"] const MAX_RECENT_SEARCHES = 10 @@ -45,7 +47,7 @@ const withPage = (model: Model, page: SearchPage): Model => modifyFields(model, /** Filters changed: the placeholder follows, and focus goes back to the editor. */ const withFilters = (model: Model, page: SearchPage, filters: ReadonlyArray<SearchFilter>): Return => { - const next = { ...page, filters, selectedIndex: 0 } + const next = modifyFields(page, { filters: () => filters, selectedIndex: () => 0 }) return { model: withPage(model, next), commands: [SetSearchPlaceholder({ placeholder: searchPlaceholder(next) }), FocusSearchEditor()], @@ -90,7 +92,12 @@ const loadRecent = (model: Model, page: SearchPage, index: number): Return => { const recent = model.recentSearches[index] if (recent === undefined) return { model } const rawInput = [filterString(recent.filters), recent.query].filter(Boolean).join(" ") - const next = { ...page, query: recent.query, rawInput, filters: recent.filters, selectedIndex: 0 } + const next = modifyFields(page, { + query: () => recent.query, + rawInput: () => rawInput, + filters: () => recent.filters, + selectedIndex: () => 0, + }) return { model: withPage(model, next), commands: [SetSearchText({ text: rawInput }), SetSearchPlaceholder({ placeholder: searchPlaceholder(next) })], @@ -101,7 +108,7 @@ const moveSelection = (model: Model, page: SearchPage, step: 1 | -1): Return => const count = model.search.hasQuery ? model.search.results.length : model.recentSearches.length if (step === 1 && count === 0) return { model } const selectedIndex = step === 1 ? Math.min(page.selectedIndex + 1, count - 1) : Math.max(page.selectedIndex - 1, 0) - return { model: withPage(model, { ...page, selectedIndex }) } + return { model: withPage(model, modifyFields(page, { selectedIndex: () => selectedIndex })) } } const pressedAutocompleteKey = (model: Model, page: SearchPage, key: string): Return => { @@ -113,55 +120,80 @@ const pressedAutocompleteKey = (model: Model, page: SearchPage, key: string): Re return { model: modifyFields(model, { suggestionIndex: () => suggestionIndex }) } } -const searchStep = (model: Model, page: SearchPage, message: SearchMessage, shared: Shared): Return => { - if (message._tag === "EditedSearch") { - const isSameTrigger = model.searchAutocomplete?.filterType === message.autocomplete?.filterType - return { - model: modifyFields(withPage(model, { ...page, rawInput: message.text, query: parseSearchInput(message.text).textQuery, selectedIndex: 0 }), { - searchAutocomplete: () => message.autocomplete, - suggestionIndex: (index) => (message.autocomplete !== null && isSameTrigger ? index : 0), - }), - } +const editedSearch = (model: Model, page: SearchPage, message: Extract<SearchMessage, { _tag: "EditedSearch" }>) => { + const isSameTrigger = model.searchAutocomplete?.filterType === message.autocomplete?.filterType + const next = modifyFields(page, { + rawInput: () => message.text, + query: () => parseSearchInput(message.text).textQuery, + selectedIndex: () => 0, + }) + return { + model: modifyFields(withPage(model, next), { + searchAutocomplete: () => message.autocomplete, + suggestionIndex: (index) => (message.autocomplete !== null && isSameTrigger ? index : 0), + }), } - if (message._tag === "PressedSearchKey") - return message.key === "ArrowDown" - ? moveSelection(model, page, 1) - : message.key === "ArrowUp" - ? moveSelection(model, page, -1) - : message.key === "BackspaceAtStart" - ? page.filters.length > 0 - ? withFilters(model, page, page.filters.slice(0, -1)) - : { model } - : model.search.hasQuery - ? openResult(model, page, page.selectedIndex, shared) - : loadRecent(model, page, page.selectedIndex) - if (message._tag === "PressedAutocompleteKey") return pressedAutocompleteKey(model, page, message.key) - if (message._tag === "HoveredSuggestion") return { model: modifyFields(model, { suggestionIndex: () => message.index }) } - if (message._tag === "ClickedSuggestion") return selectSuggestion(model, page, model.suggestions[message.index]) - if (message._tag === "UpdatedSuggestions") { - const count = message.suggestions.length - return { - model: modifyFields(model, { - suggestions: () => message.suggestions, - // The legacy clamp effect: keep the active option inside the list. - suggestionIndex: (index) => (count > 0 && index >= count ? count - 1 : index), - }), - commands: [SyncSuggestionCount({ count })], - } +} + +const pressedSearchKey = (model: Model, page: SearchPage, key: SearchKey, shared: Shared): Return => + Match.value(key).pipe( + Match.withReturnType<Return>(), + Match.when("ArrowDown", () => moveSelection(model, page, 1)), + Match.when("ArrowUp", () => moveSelection(model, page, -1)), + Match.when("BackspaceAtStart", () => + page.filters.length > 0 ? withFilters(model, page, page.filters.slice(0, -1)) : { model }, + ), + Match.when("Enter", () => + model.search.hasQuery + ? openResult(model, page, page.selectedIndex, shared) + : loadRecent(model, page, page.selectedIndex), + ), + Match.exhaustive, + ) + +const updatedSuggestions = (model: Model, suggestions: ReadonlyArray<Suggestion>): Return => { + const count = suggestions.length + return { + model: modifyFields(model, { + suggestions: () => suggestions, + // The legacy clamp effect: keep the active option inside the list. + suggestionIndex: (index) => (count > 0 && index >= count ? count - 1 : index), + }), + commands: [SyncSuggestionCount({ count })], } - if (message._tag === "ClickedRemoveFilter") - return withFilters(model, page, page.filters.filter((_, index) => index !== message.index)) - if (message._tag === "ClickedClearSearch") { - const next = { ...page, query: "", rawInput: "", filters: [], selectedIndex: 0 } - return { - model: withPage(model, next), - commands: [SetSearchText({ text: "" }), SetSearchPlaceholder({ placeholder: searchPlaceholder(next) })], - } +} + +const clearedSearch = (model: Model, page: SearchPage): Return => { + const next = modifyFields(page, { + query: () => "", + rawInput: () => "", + filters: () => [], + selectedIndex: () => 0, + }) + return { + model: withPage(model, next), + commands: [SetSearchText({ text: "" }), SetSearchPlaceholder({ placeholder: searchPlaceholder(next) })], } - if (message._tag === "ClickedSearchResult") return openResult(model, page, message.index, shared) - if (message._tag === "ClickedRecentSearch") return loadRecent(model, page, message.index) - return { model: modifyFields(model, { recentSearches: () => [] }), commands: [SaveRecentSearches({ searches: [] })] } } +const searchStep = (model: Model, page: SearchPage, message: SearchMessage, shared: Shared): Return => + Match.valueTags(message, { + EditedSearch: (edited): Return => editedSearch(model, page, edited), + PressedSearchKey: ({ key }) => pressedSearchKey(model, page, key, shared), + PressedAutocompleteKey: ({ key }) => pressedAutocompleteKey(model, page, key), + HoveredSuggestion: ({ index }): Return => ({ model: modifyFields(model, { suggestionIndex: () => index }) }), + ClickedSuggestion: ({ index }) => selectSuggestion(model, page, model.suggestions[index]), + UpdatedSuggestions: ({ suggestions }) => updatedSuggestions(model, suggestions), + ClickedRemoveFilter: ({ index }) => + withFilters(model, page, page.filters.filter((_, filterIndex) => filterIndex !== index)), + ClickedClearSearch: () => clearedSearch(model, page), + ClickedSearchResult: ({ index }) => openResult(model, page, index, shared), + ClickedRecentSearch: ({ index }) => loadRecent(model, page, index), + ClickedClearRecentSearches: (): Return => ({ + model: modifyFields(model, { recentSearches: () => [] }), + commands: [SaveRecentSearches({ searches: [] })], + }), + }) + export const updateSearch = (model: Model, message: SearchMessage, shared: Shared): Return => model.page._tag === "Search" ? searchStep(model, model.page, message, shared) : { model } diff --git a/apps/web-foldkit/src/overlay/command-palette/update.ts b/apps/web-foldkit/src/overlay/command-palette/update.ts index a8d5ebeb7..81312de27 100644 --- a/apps/web-foldkit/src/overlay/command-palette/update.ts +++ b/apps/web-foldkit/src/overlay/command-palette/update.ts @@ -4,7 +4,9 @@ import { Command, type Update } from "foldkit" import { modifyFields } from "foldkit/struct" import type { Shared } from "../../page/contract" import { AppRoute, orgHrefOf } from "../../route" -import type { HazelRpc } from "../../rpc" +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf, reasonToast, successToast } from "../../data/actions" +import type { Resources } from "../../rpc" import * as CommandMenu from "../../ui/command-menu" import type { ToastRequest } from "../toasts" import { @@ -18,11 +20,10 @@ import { Message, OutMessage } from "./message" import { initialPageState, isFormPage, type Model, type Page, type PageState } from "./model" import { emptySearchData } from "./search-data" import { menuSectionsOf, NAVIGATION, parseKey, SETTINGS, STATUS_OPTIONS, THEME_OPTIONS } from "./menu" -import { LoadRecentSearches } from "./search-mount" +import { FocusSearchEditor, LoadRecentSearches } from "./search-mount" import { updateSearch } from "./search-update" -import { successToast } from "../../data/actions" -export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, HazelRpc> +export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, Resources> export const MENU_ID = "command-palette" export const CREATE_CHANNEL_INPUT_ID = "command-palette-create-channel-name" @@ -63,14 +64,14 @@ const withMenu = (model: Model, shared: Shared, inputValue: string): Model => }), }) -/** The new page's `autoFocus` input (the search editor focuses itself on mount). */ +/** The new page's `autoFocus` input (and the search page's stored recent searches). */ const focusCommandsOf = (page: PageState) => page._tag === "CreateChannel" ? [FocusInput({ selector: `#${CREATE_CHANNEL_INPUT_ID}` })] : page._tag === "JoinChannel" ? [FocusInput({ selector: `#${JOIN_CHANNEL_INPUT_ID}` })] : page._tag === "Search" - ? [LoadRecentSearches()] + ? [LoadRecentSearches(), FocusSearchEditor()] : [FocusInput({ selector: `#${CommandMenu.searchId(MENU_ID)}` })] const showPage = (model: Model, page: PageState, history: ReadonlyArray<PageState>, shared: Shared): Return => ({ @@ -235,10 +236,10 @@ export const update = (model: Model, message: Message, shared: Shared): Return = SubmittedCreateChannel: () => submittedCreateChannel(model, shared), SucceededCreateChannel: ({ channelId }) => closedWith(model, chatHref(shared, channelId), successToast("Channel created successfully")), - FailedCreateChannel: ({ toast }) => ({ + FailedCreateChannel: ({ reason }) => ({ model: model.page._tag === "CreateChannel" ? withPage(model, { ...model.page, isSubmitting: false }) : model, - outMessage: OutMessage.RequestedToast({ toast }), + outMessage: OutMessage.RequestedToast({ toast: reasonToast(reason) }), }), ChangedJoinSearch: ({ value }) => model.page._tag === "JoinChannel" ? { model: withPage(model, { ...model.page, searchQuery: value }) } : { model }, @@ -247,7 +248,16 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ? { model } : { model, commands: [JoinChannel({ channelId, userId: shared.currentUser.id })] }, SucceededJoinChannel: () => closedWith(model, null, successToast("Successfully joined channel")), - FailedJoinChannel: ({ toast }) => ({ model, outMessage: OutMessage.RequestedToast({ toast }) }), + FailedJoinChannel: ({ reason }) => ({ + model, + outMessage: OutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { title: "Channel not found", description: "This channel may have been deleted." }, + }), + ), + }), + }), EditedSearch: (found) => updateSearch(model, found, shared), PressedSearchKey: (found) => updateSearch(model, found, shared), PressedAutocompleteKey: (found) => updateSearch(model, found, shared), @@ -265,11 +275,17 @@ export const update = (model: Model, message: Message, shared: Shared): Return = CompletedTrackRecentChannel: () => ({ model }), SucceededSetPresenceStatus: () => ({ model }), FailedSetPresenceStatus: () => ({ model }), - CompletedSetSearchText: () => ({ model }), - CompletedDeleteFilterText: () => ({ model }), - CompletedSyncSuggestionCount: () => ({ model }), - CompletedSetSearchPlaceholder: () => ({ model }), - CompletedFocusSearchEditor: () => ({ model }), + // The editor follows the Model; a page that closed first has no editor left to update. + SucceededSetSearchText: () => ({ model }), + FailedSetSearchText: () => ({ model }), + SucceededDeleteFilterText: () => ({ model }), + FailedDeleteFilterText: () => ({ model }), + SucceededSyncSuggestionCount: () => ({ model }), + FailedSyncSuggestionCount: () => ({ model }), + SucceededSetSearchPlaceholder: () => ({ model }), + FailedSetSearchPlaceholder: () => ({ model }), + SucceededFocusSearchEditor: () => ({ model }), + FailedFocusSearchEditor: () => ({ model }), CompletedSaveRecentSearches: () => ({ model }), }), shared, diff --git a/apps/web-foldkit/src/overlay/modal.story.test.ts b/apps/web-foldkit/src/overlay/modal.story.test.ts new file mode 100644 index 000000000..50926c7d4 --- /dev/null +++ b/apps/web-foldkit/src/overlay/modal.story.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { ChannelId, MessageId } from "@hazel/schema" +import { Option, Schema } from "effect" +import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" +import { describe, expect, test } from "vitest" +import { errorToastOf, reasonToast, successToast } from "../data/actions" +import { AppRoute } from "../route" +import { signedInShared } from "../test/root-fixtures" +import * as Modal from "./modal" +import * as DeleteMessage from "./modal/delete-message" +import type { ModalRequest } from "./modal/requests" +import * as RenameChannel from "./modal/rename-channel" +import { closed, completed, ModalOutMessage } from "./out-message" + +/** The root modal slot: each opening is an instance, and a result reaches the instance that asked for it. */ + +const channelId = Schema.decodeSync(ChannelId)("00000000-0000-4000-8000-0000000000c1") +const messageId = Schema.decodeSync(MessageId)("00000000-0000-4000-8000-0000000000d1") +const context: Modal.ModalContext = { + ...signedInShared(), + route: AppRoute.ChatChannel({ orgSlug: "hazel", channelId }), +} + +const run = (state: Modal.Model, modalMessage: Modal.Message) => { + const transition = Modal.update(state, modalMessage, context) + return { + model: transition.model, + commands: transition.commands ?? [], + outMessage: Option.getOrUndefined(transition.outMessage), + } +} + +const opened = (state: Modal.Model, request: ModalRequest) => Modal.open(state, request, context).model + +const deleteMessage = (instance: number, inner: typeof DeleteMessage.Message.Type): Modal.Message => ({ + _tag: "DeleteMessage", + instance, + message: inner, +}) +const renameChannel = (instance: number | null, inner: typeof RenameChannel.Message.Type): Modal.Message => ({ + _tag: "RenameChannel", + instance, + message: inner, +}) + +describe("delete message", () => { + test("a failure after the modal closed still toasts why the message came back", () => { + story( + run, + given(opened(Modal.initialModel, { _tag: "DeleteMessage", messageId })), + message(deleteMessage(0, DeleteMessage.Message.ClickedDelete())), + Command.expectExact(DeleteMessage.DeleteMessage({ messageId })), + expectOutMessage(closed), + model((m) => expect(m.current).toBeNull()), + Command.resolve(DeleteMessage.DeleteMessage, DeleteMessage.Message.FailedDeleteMessage({ reason: "NotFound" })), + expectOutMessage( + ModalOutMessage.RequestedToast({ + toast: errorToastOf({ title: "Message not found", description: "It may have already been deleted." }), + }), + ), + model((m) => { + expect(m.current).toBeNull() + expect(m.retired).toEqual([]) + }), + ) + }) + + test("a common failure gets the shared copy", () => { + story( + run, + given(opened(Modal.initialModel, { _tag: "DeleteMessage", messageId })), + message(deleteMessage(0, DeleteMessage.Message.ClickedDelete())), + Command.resolve(DeleteMessage.DeleteMessage, DeleteMessage.Message.FailedDeleteMessage({ reason: "Offline" })), + expectOutMessage(ModalOutMessage.RequestedToast({ toast: reasonToast("Offline") })), + ) + }) +}) + +describe("instances", () => { + const renaming = Modal.open(Modal.initialModel, { _tag: "RenameChannel", channelId }, context).model + const channel = { id: channelId, name: "general" } + + // Subscription Messages carry no instance: they follow the open one. + const loaded = run(renaming, renameChannel(null, RenameChannel.Message.UpdatedChannel({ channel }))).model + const named = run(loaded, renameChannel(0, RenameChannel.Message.ChangedName({ value: "design" }))).model + + test("a reopened modal is not closed by the old instance's success, which still reports", () => { + const submitted = run(named, renameChannel(0, RenameChannel.Message.SubmittedForm())) + expect(submitted.commands.map((command) => command.name)).toEqual(["RenameChannel"]) + // Closed while the rename runs, then opened again: the rename's result belongs to instance 0. + const cancelled = run(submitted.model, renameChannel(0, RenameChannel.Message.ClickedCancel())) + expect(cancelled.outMessage).toEqual(closed) + const reopened = opened(cancelled.model, { _tag: "RenameChannel", channelId }) + story( + run, + given(reopened), + model((m) => expect(m.current).toMatchObject({ _tag: "RenameChannel", instance: 1 })), + message(renameChannel(0, RenameChannel.Message.SucceededRenameChannel())), + expectOutMessage(completed({ toast: successToast("Channel renamed successfully") })), + model((m) => { + expect(m.current).toMatchObject({ _tag: "RenameChannel", instance: 1 }) + expect(m.current?.model).toMatchObject({ name: "", isSubmitting: false }) + expect(m.retired).toEqual([]) + }), + ) + }) + + test("a late failure of a closed instance still toasts, and leaves the open one alone", () => { + const submitted = run(named, renameChannel(0, RenameChannel.Message.SubmittedForm())).model + const reopened = opened(submitted, { _tag: "RenameChannel", channelId }) + story( + run, + given(reopened), + message(renameChannel(0, RenameChannel.Message.FailedRenameChannel({ reason: "Offline" }))), + expectOutMessage(ModalOutMessage.RequestedToast({ toast: reasonToast("Offline") })), + model((m) => { + expect(m.current).toMatchObject({ _tag: "RenameChannel", instance: 1 }) + expect(m.retired).toEqual([]) + }), + ) + }) + + test("the open instance's own result still closes it", () => { + story( + run, + given(renaming), + message(renameChannel(null, RenameChannel.Message.UpdatedChannel({ channel }))), + message(renameChannel(0, RenameChannel.Message.ChangedName({ value: "design" }))), + message(renameChannel(0, RenameChannel.Message.SubmittedForm())), + Command.resolve(RenameChannel.RenameChannel, RenameChannel.Message.SucceededRenameChannel()), + expectOutMessage(completed({ toast: successToast("Channel renamed successfully") })), + model((m) => expect(m.current).toBeNull()), + ) + }) + + test("a Message for an instance that is neither open nor retired is dropped", () => { + const result = run(renaming, renameChannel(7, RenameChannel.Message.ClickedCancel())) + expect(result.model).toBe(renaming) + expect(result.outMessage).toBeUndefined() + }) +}) diff --git a/apps/web-foldkit/src/overlay/modal.ts b/apps/web-foldkit/src/overlay/modal.ts index 82f702fb8..5de894c0c 100644 --- a/apps/web-foldkit/src/overlay/modal.ts +++ b/apps/web-foldkit/src/overlay/modal.ts @@ -1,10 +1,11 @@ import { Array, Option, Schema } from "effect" import { Subscription } from "foldkit" import type { Command } from "foldkit" +import { modifyFields } from "foldkit/struct" import type { Html, HtmlBuilder } from "foldkit/html" import type { Shared } from "../page/contract" -import type { HazelRpc } from "../rpc" -import type { ModalHost, ModalMessageBase, ModalSlotBase, ModalStep } from "./modal/contract" +import type { Resources } from "../rpc" +import type { ModalContext, ModalHost, ModalMessageBase, ModalStep } from "./modal/contract" import * as ChangeRole from "./modal/change-role" import * as CreateBot from "./modal/create-bot" import * as CreateDm from "./modal/create-dm" @@ -26,7 +27,7 @@ import * as RenameThread from "./modal/rename-thread" import * as RequestIntegration from "./modal/request-integration" import * as SetStatus from "./modal/set-status" import type { ModalRequest } from "./modal/requests" -import type { ModalOutMessage } from "./out-message" +import { ModalOutMessage } from "./out-message" /** * Root modal slot (legacy `atoms/modal-atoms`, the global modals in `$orgSlug/layout.tsx`, and the @@ -58,73 +59,148 @@ const modals = [ /** What a page or the shell asks for. One variant per legacy modal. */ export { ModalRequest } from "./modal/requests" -export const Model = Schema.NullOr(Schema.Union(modals.map((modal) => modal.Slot))) +const Slot = Schema.Union(modals.map((modal) => modal.Slot)) +type Slot = typeof Slot.Type + +/** + * The open modal, and the instances that closed (or were replaced) while their Commands may still be + * running: a late result still delivers its toast and completion, but never touches the open one. + */ +export const Model = Schema.Struct({ + current: Schema.NullOr(Slot), + /** Newest first, at most `RETIRED_LIMIT`; an instance leaves once it has reported an OutMessage. */ + retired: Schema.Array(Slot), + /** The number the next opening gets. */ + nextInstance: Schema.Number, +}) export type Model = typeof Model.Type +export const initialModel: Model = { current: null, retired: [], nextInstance: 0 } + export const Message = Schema.Union(modals.map((modal) => modal.Wrapped)) export type Message = typeof Message.Type +export type { ModalContext } from "./modal/contract" + export interface Transition { readonly model: Model - readonly commands?: ReadonlyArray<Command.Command<Message, never, HazelRpc>> + readonly commands?: ReadonlyArray<Command.Command<Message, never, Resources>> readonly outMessage: Option.Option<ModalOutMessage> } -type Slot = NonNullable<Model> +const RETIRED_LIMIT = 8 const settled = (model: Model): Transition => ({ model, outMessage: Option.none() }) -/** A closing OutMessage drops the slot; the root still sees it for the toast and navigation. */ -const toTransition = (found: Option.Option<ModalStep<Slot, Message>>, fallback: Model): Transition => - Option.match(found, { - onNone: () => settled(fallback), - onSome: (step) => ({ - model: Option.match(step.outMessage, { - onNone: () => step.slot, - onSome: (outMessage) => (outMessage._tag === "RequestedToast" ? step.slot : null), - }), - commands: step.commands, - outMessage: step.outMessage, - }), +const retire = (retired: ReadonlyArray<Slot>, slot: Slot): ReadonlyArray<Slot> => + [slot, ...retired.filter((entry) => entry.instance !== slot.instance)].slice(0, RETIRED_LIMIT) + +/** Every OutMessage but a toast closes the modal; the root still sees it for the toast and navigation. */ +const isClosing = (outMessage: Option.Option<ModalOutMessage>) => + Option.match(outMessage, { + onNone: () => false, + onSome: ModalOutMessage.match<boolean>({ Closed: () => true, Completed: () => true, RequestedToast: () => false }), }) -const modalById = (id: string) => Array.findFirst(modals, (modal) => modal.id === id) +/** A step of the open instance: a closing OutMessage retires it. */ +const currentStep = (model: Model, step: ModalStep<Slot, Message>): Transition => ({ + model: isClosing(step.outMessage) + ? modifyFields(model, { current: () => null, retired: (retired) => retire(retired, step.slot) }) + : modifyFields(model, { current: () => step.slot }), + commands: step.commands, + outMessage: step.outMessage, +}) + +/** A step of a closed instance: its OutMessage goes to the root, and reporting one ends it. */ +const retiredStep = (model: Model, step: ModalStep<Slot, Message>): Transition => ({ + model: modifyFields(model, { + retired: (retired) => + Option.isSome(step.outMessage) + ? retired.filter((entry) => entry.instance !== step.slot.instance) + : retired.map((entry) => (entry.instance === step.slot.instance ? step.slot : entry)), + }), + commands: step.commands, + outMessage: step.outMessage, +}) + +/** A registry entry with its own Slot and Message widened to the registry's unions. */ +interface RegisteredModal { + readonly id: string + readonly init: (request: unknown, context: ModalContext, instance: number) => Option.Option<ModalStep<Slot, Message>> + readonly update: ( + slot: Slot, + message: ModalMessageBase, + context: ModalContext, + ) => Option.Option<ModalStep<Slot, Message>> +} +const registered: ReadonlyArray<RegisteredModal> = modals + +const modalById = (id: string) => Array.findFirst(registered, (modal) => modal.id === id) /** Opening replaces whatever modal is open, like the legacy atoms (one visible at a time). */ -export const open = (_model: Model, request: ModalRequest, shared: Shared): Transition => - Option.match(modalById(request._tag), { - onNone: () => settled(null), - onSome: (modal) => toTransition(modal.init(request, shared), null), +export const open = (model: Model, request: ModalRequest, context: ModalContext): Transition => { + const instance = model.nextInstance + const opened = modifyFields(model, { + current: () => null, + retired: (retired) => (model.current === null ? retired : retire(retired, model.current)), + nextInstance: () => instance + 1, }) + return Option.match( + Option.flatMap(modalById(request._tag), (modal) => modal.init(request, context, instance)), + { + onNone: () => settled(opened), + onSome: (step) => currentStep(opened, step), + }, + ) +} -export const update = (model: Model, message: ModalMessageBase, shared: Shared): Transition => - model === null - ? settled(model) - : Option.match(modalById(message._tag), { - onNone: () => settled(model), - onSome: (modal) => toTransition(modal.update(model, message, shared), model), - }) +/** + * Routes a Message to the instance that sent it: the open one, or a retired one whose Command + * finished after it closed. A Subscription's Message (no instance) goes to the open one. + */ +export const update = (model: Model, message: ModalMessageBase, context: ModalContext): Transition => { + const current = model.current + const isForCurrent = + current !== null && current._tag === message._tag && (message.instance === null || message.instance === current.instance) + if (isForCurrent) + return Option.match( + Option.flatMap(modalById(message._tag), (modal) => modal.update(current, message, context)), + { onNone: () => settled(model), onSome: (step) => currentStep(model, step) }, + ) + const retired = Array.findFirst( + model.retired, + (slot) => slot._tag === message._tag && slot.instance === message.instance, + ) + return Option.match( + Option.flatMap(retired, (slot) => + Option.flatMap(modalById(message._tag), (modal) => modal.update(slot, message, context)), + ), + { onNone: () => settled(model), onSome: (step) => retiredStep(model, step) }, + ) +} -export const subscriptions = Subscription.aggregate<ModalHost, Message, HazelRpc>()( +export const subscriptions = Subscription.aggregate<ModalHost, Message, Resources>()( ...modals.map((modal) => modal.subscriptions), ) export const view = <ParentMessage>( h: HtmlBuilder<ParentMessage>, - model: ModalSlotBase | null, + model: Model, shared: Shared, toParentMessage: (message: Message) => ParentMessage, -): Html => - model === null +): Html => { + const current = model.current + return current === null ? h.empty - : Option.match(modalById(model._tag), { + : Option.match(Array.findFirst(modals, (modal) => modal.id === current._tag), { onNone: () => h.empty, onSome: (modal) => h.submodel({ - slotId: `modal:${model._tag}`, - model, + slotId: `modal:${current._tag}:${current.instance}`, + model: current, view: modal.hostView, viewInputs: { shared }, toParentMessage, }), }) +} diff --git a/apps/web-foldkit/src/overlay/modal/change-role.ts b/apps/web-foldkit/src/overlay/modal/change-role.ts index 09203245a..ef8e480b6 100644 --- a/apps/web-foldkit/src/overlay/modal/change-role.ts +++ b/apps/web-foldkit/src/overlay/modal/change-role.ts @@ -1,4 +1,4 @@ -import { OrganizationMemberId, UserId } from "@hazel/schema" +import { OrganizationMemberId } from "@hazel/schema" import { Effect, Schema } from "effect" import { Command, Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" @@ -11,7 +11,7 @@ import { radioGroup } from "../../ui/radio" import { closed, completed, ModalOutMessage } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { errorToast, successToast } from "../../data/actions" /** `components/modals/change-role-modal.tsx` (legacy `useModal("change-role")`). */ @@ -82,7 +82,7 @@ const clickedUpdateRole = (model: Model): Return => { const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), // The kit radio has no per-radio isDisabled, so a disabled role is refused here. ChangedRole: ({ value }) => isRoleDisabled(model, value) ? { model } : { model: modifyFields(model, { selectedRole: () => value }) }, diff --git a/apps/web-foldkit/src/overlay/modal/contract.ts b/apps/web-foldkit/src/overlay/modal/contract.ts index e772b0349..927fb3f15 100644 --- a/apps/web-foldkit/src/overlay/modal/contract.ts +++ b/apps/web-foldkit/src/overlay/modal/contract.ts @@ -1,14 +1,23 @@ import { Option, Schema } from "effect" import { Command, Submodel, Subscription, type Update } from "foldkit" import type { Shared } from "../../page/contract" -import type { HazelRpc } from "../../rpc" +import type { AppRoute } from "../../route" +import type { Resources } from "../../rpc" import type { ModalOutMessage } from "../out-message" /** * The modal contract: each legacy `useModal` id (and each page-local modal) is a Submodel the root * opens from a `RequestedModal`. Mirrors `page/contract.ts`: registered once in `overlay/modal.ts`. + * + * Every opening is a new instance with its own number. Slots and Messages carry it, so a Command's + * result reaches the instance that issued it, even after that one closed or another replaced it. */ +/** What `init` and `update` see: the shared state plus the route the modal opened over. */ +export interface ModalContext extends Shared { + readonly route: AppRoute +} + export interface ModalViewInputs { readonly shared: Shared } @@ -18,34 +27,40 @@ export interface ModalSubscriptionInput<Model> { readonly shared: Shared } -export type ModalReturn<Model, Message> = Update.ReturnWithOutMessage<Model, Message, ModalOutMessage, HazelRpc> +export type ModalReturn<Model, Message> = Update.ReturnWithOutMessage<Model, Message, ModalOutMessage, Resources> export interface ModalSpec<Request, Model, Message> { - readonly init: (request: Request, shared: Shared) => ModalReturn<Model, Message> - readonly update: (model: Model, message: Message, shared: Shared) => ModalReturn<Model, Message> + readonly init: (request: Request, context: ModalContext) => ModalReturn<Model, Message> + readonly update: (model: Model, message: Message, context: ModalContext) => ModalReturn<Model, Message> readonly view: Submodel.View<Model, Message, ModalViewInputs> - readonly subscriptions?: Subscription.Subscriptions<ModalSubscriptionInput<Model>, Message, HazelRpc> + readonly subscriptions?: Subscription.Subscriptions<ModalSubscriptionInput<Model>, Message, Resources> } export interface ModalSlotBase { readonly _tag: string + readonly instance: number } export interface ModalMessageBase { readonly _tag: string + /** The instance that sent it; `null` from a Subscription, which always follows the open instance. */ + readonly instance: number | null } export interface ModalHost { - readonly modal: ModalSlotBase | null + readonly modal: { readonly current: ModalSlotBase | null } readonly shared: Shared } export interface ModalStep<Slot, Wrapped> { readonly slot: Slot - readonly commands: ReadonlyArray<Command.Command<Wrapped, never, HazelRpc>> + readonly commands: ReadonlyArray<Command.Command<Wrapped, never, Resources>> readonly outMessage: Option.Option<ModalOutMessage> } -/** Request `{ _tag: id, ...fields }` (from `requests.ts`), slot `{ _tag: id, model }`, Messages `{ _tag: id, message }`. */ -/** The request schema comes from `requests.ts`, so out-messages stay free of modal views. */ +/** + * Request `{ _tag: id, ...fields }` (from `requests.ts`), slot `{ _tag: id, instance, model }`, Messages + * `{ _tag: id, instance, message }`. The request schema comes from `requests.ts`, so out-messages stay + * free of modal views. + */ export const defineModal = < const Id extends string, RequestSchema extends Schema.Top & { readonly Type: { readonly _tag: Id } }, @@ -58,53 +73,54 @@ export const defineModal = < ) => { type Model = ModelSchema["Type"] type Message = MessageSchema["Type"] - const Request = schemas.request - const Slot = Schema.TaggedStruct(id, { model: schemas.Model }) - const Wrapped = Schema.TaggedStruct(id, { message: schemas.Message }) - type RequestType = typeof Request.Type - type SlotType = { readonly _tag: Id; readonly model: Model } - type WrappedType = { readonly _tag: Id; readonly message: Message } + const Slot = Schema.TaggedStruct(id, { instance: Schema.Number, model: schemas.Model }) + const Wrapped = Schema.TaggedStruct(id, { instance: Schema.NullOr(Schema.Number), message: schemas.Message }) + type SlotType = { readonly _tag: Id; readonly instance: number; readonly model: Model } + type WrappedType = { readonly _tag: Id; readonly instance: number | null; readonly message: Message } // The tags are unique across the registry, so a matching tag is this modal's value. - const isOwnRequest = Schema.is(Request) + const isOwnRequest = Schema.is(schemas.request) const isOwnSlot = (slot: ModalSlotBase): slot is SlotType => slot._tag === id const isOwnMessage = (message: ModalMessageBase): message is WrappedType => message._tag === id - const wrap = (message: Message): WrappedType => ({ _tag: id, message }) - const step = (result: ModalReturn<Model, Message>): ModalStep<SlotType, WrappedType> => ({ - slot: { _tag: id, model: result.model }, - commands: Command.mapMessages(result.commands, wrap), + const wrapFor = + (instance: number | null) => + (message: Message): WrappedType => ({ _tag: id, instance, message }) + const step = (instance: number, result: ModalReturn<Model, Message>): ModalStep<SlotType, WrappedType> => ({ + slot: { _tag: id, instance, model: result.model }, + commands: Command.mapMessages(result.commands, wrapFor(instance)), outMessage: Option.fromNullishOr(result.outMessage), }) const lifted = Subscription.lift(spec.subscriptions ?? {})<ModalHost, WrappedType>({ read: (host) => - host.modal !== null && isOwnSlot(host.modal) - ? Option.some({ model: host.modal.model, shared: host.shared }) + host.modal.current !== null && isOwnSlot(host.modal.current) + ? Option.some({ model: host.modal.current.model, shared: host.shared }) : Option.none(), - toParentMessage: wrap, + toParentMessage: wrapFor(null), }) return { id, - Request, + Request: schemas.request, Slot, Wrapped, - init: (request: unknown, shared: Shared) => + init: (request: unknown, context: ModalContext, instance: number) => isOwnRequest(request) - ? Option.some(step(spec.init(request, shared))) + ? Option.some(step(instance, spec.init(request, context))) : Option.none<ModalStep<SlotType, WrappedType>>(), - update: (slot: ModalSlotBase, message: ModalMessageBase, shared: Shared) => + /** Runs `message` on `slot`; the caller has already picked the slot by the message's instance. */ + update: (slot: ModalSlotBase, message: ModalMessageBase, context: ModalContext) => isOwnSlot(slot) && isOwnMessage(message) - ? Option.some(step(spec.update(slot.model, message.message, shared))) + ? Option.some(step(slot.instance, spec.update(slot.model, message.message, context))) : Option.none<ModalStep<SlotType, WrappedType>>(), hostView: Submodel.defineView<ModalSlotBase, WrappedType, ModalViewInputs>((slot, inputs, h) => isOwnSlot(slot) ? h.submodel({ - slotId: `modal:${id}`, + slotId: `modal:${id}:${slot.instance}`, model: slot.model, view: spec.view, viewInputs: inputs, - toParentMessage: wrap, + toParentMessage: wrapFor(slot.instance), }) : h.empty, ), diff --git a/apps/web-foldkit/src/overlay/modal/create-bot.ts b/apps/web-foldkit/src/overlay/modal/create-bot.ts index 0894364d3..08c2ed5bb 100644 --- a/apps/web-foldkit/src/overlay/modal/create-bot.ts +++ b/apps/web-foldkit/src/overlay/modal/create-bot.ts @@ -22,7 +22,7 @@ import { import { botTokenDisplay } from "./bot-token-display" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { errorToast, failureToast, successToast } from "../../data/actions" /** @@ -117,7 +117,7 @@ const toast = (request: ToastRequest) => ModalOutMessage.RequestedToast({ toast: const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ChangedName: ({ value }) => ({ model: changedName(model, value) }), ChangedDescription: ({ value }) => ({ model: changedDescription(model, value) }), ToggledScope: ({ scope }) => ({ model: toggledScope(model, scope) }), diff --git a/apps/web-foldkit/src/overlay/modal/create-dm.ts b/apps/web-foldkit/src/overlay/modal/create-dm.ts index a6303591b..d12a08e9d 100644 --- a/apps/web-foldkit/src/overlay/modal/create-dm.ts +++ b/apps/web-foldkit/src/overlay/modal/create-dm.ts @@ -1,8 +1,7 @@ import { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Effect, Schema, Stream } from "effect" -import { Command, Submodel, Subscription } from "foldkit" +import { Command, Subscription } from "foldkit" import * as Dom from "foldkit/dom" -import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { HazelRpc } from "../../rpc" import type { Shared } from "../../page/contract" @@ -13,7 +12,7 @@ import { defineModal, type ModalReturn, type ModalSubscriptionInput } from "./co import { findExistingDmChannel, organizationUsersStream } from "./create-dm-data" import { Message, Model, SEARCH_ID } from "./create-dm-model" import { view } from "./create-dm-view" -import { initFrame, isFrameClosed } from "./frame" +import { initFrame, isCloseRequest, isPortalReady } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-dm-modal.tsx` (legacy `useModal("create-dm")`). */ @@ -88,9 +87,9 @@ const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { // `autoFocus`: after the frame has portaled and focused its dialog. GotFrameMessage: ({ message }) => - isFrameClosed(model.frame, message) + isCloseRequest(message) ? { model, outMessage: closed } - : message._tag === "CompletedPortalModal" + : isPortalReady(message) ? { model, commands: [FocusSearch()] } : { model }, ChangedSearch: ({ value }) => ({ model: modifyFields(model, { searchQuery: () => value }) }), diff --git a/apps/web-foldkit/src/overlay/modal/create-organization.ts b/apps/web-foldkit/src/overlay/modal/create-organization.ts index b3fdc4a80..6b12c3138 100644 --- a/apps/web-foldkit/src/overlay/modal/create-organization.ts +++ b/apps/web-foldkit/src/overlay/modal/create-organization.ts @@ -14,7 +14,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-organization-modal.tsx` (legacy `useModal("create-organization")`). */ @@ -101,7 +101,7 @@ const submitted = (model: Model): Return => { const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value, errors: () => validate(value, model.slug) }), }), diff --git a/apps/web-foldkit/src/overlay/modal/create-section.ts b/apps/web-foldkit/src/overlay/modal/create-section.ts index 7ef58aeac..108b4a6cc 100644 --- a/apps/web-foldkit/src/overlay/modal/create-section.ts +++ b/apps/web-foldkit/src/overlay/modal/create-section.ts @@ -12,7 +12,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-section-modal.tsx` (legacy `useModal("create-section")`). */ @@ -66,7 +66,7 @@ const submitted = (model: Model, shared: Shared): Return => { const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value, nameError: () => validateName(value) }), }), diff --git a/apps/web-foldkit/src/overlay/modal/delete-bot.ts b/apps/web-foldkit/src/overlay/modal/delete-bot.ts index 714c8b763..6f3a50538 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-bot.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-bot.ts @@ -12,7 +12,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** @@ -64,7 +64,7 @@ type Return = ModalReturn<Model, Message> const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ClickedCancel: () => ({ model, outMessage: closed }), ClickedDelete: () => model.isDeleting diff --git a/apps/web-foldkit/src/overlay/modal/delete-channel.ts b/apps/web-foldkit/src/overlay/modal/delete-channel.ts index 6a9139db0..50b527f67 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-channel.ts @@ -3,91 +3,87 @@ import { Effect, Schema } from "effect" import { Command, Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { deleteChannelAction } from "~/db/actions" -import type { Shared } from "../../page/contract" +import { CommonReason, failureMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../data/actions" import { AppRoute, hrefOf } from "../../route" import { button } from "../../ui/button" import { dialogFooter, dialogHeader } from "../../ui/dialog" import { description } from "../../ui/field" import { closed, completed, ModalOutMessage } from "../out-message" -import { ToastRequest } from "../toasts" import * as Requests from "./requests" -import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" -import { failureToast, successToast } from "../../data/actions" +import { defineModal, type ModalContext, type ModalReturn, type ModalViewInputs } from "./contract" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" /** `components/modals/delete-channel-modal.tsx`; confirming runs `deleteChannelAction`. */ const Model = Schema.Struct({ channelId: ChannelId, channelName: Schema.String, frame: Frame }) type Model = typeof Model.Type -const Message = defineMessageUnion({ +/** What can go wrong deleting a channel, beyond the shared reasons. */ +export const DeleteChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type DeleteChannelFailure = typeof DeleteChannelFailure.Type + +export const Message = defineMessageUnion({ GotFrameMessage: { message: FrameMessage }, ClickedCancel: {}, ClickedDelete: {}, - SucceededDeleteChannel: { isOnDeletedChannel: Schema.Boolean }, - FailedDeleteChannel: { toast: ToastRequest }, + SucceededDeleteChannel: {}, + FailedDeleteChannel: { reason: DeleteChannelFailure }, }) type Message = typeof Message.Type -/** Legacy fuzzy `matchRoute` on `/$orgSlug/chat/$id` or `/$orgSlug/channels/$channelId/settings`. */ -const isUnder = (pathname: string, base: string) => - pathname === base || pathname === `${base}/` || pathname.startsWith(`${base}/`) - -const DeleteChannel = Command.define("DeleteChannel", { - args: { channelId: ChannelId, orgSlug: Schema.NullOr(Schema.String) }, +export const DeleteChannel = Command.define("DeleteChannel", { + args: { channelId: ChannelId }, messages: [Message.SucceededDeleteChannel, Message.FailedDeleteChannel], - execute: ({ channelId, orgSlug }) => + execute: ({ channelId }) => deleteChannelAction({ channelId }).pipe( - Effect.andThen(() => - Effect.sync(() => { - const pathname = window.location.pathname - return Message.SucceededDeleteChannel({ - isOnDeletedChannel: - orgSlug !== null && - (isUnder(pathname, hrefOf(AppRoute.ChatChannel({ orgSlug, channelId }))) || - isUnder(pathname, hrefOf(AppRoute.ChannelSettings({ orgSlug, channelId })))), - }) - }), - ), - Effect.catchCause((cause) => - Effect.succeed( - Message.FailedDeleteChannel({ - toast: failureToast(cause, "friendly", { - ChannelNotFoundError: { - title: "Channel not found", - description: "This channel may have already been deleted.", - isRetryable: false, - }, - }), - }), - ), - ), + Effect.as(Message.SucceededDeleteChannel()), + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(Message.FailedDeleteChannel({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedDeleteChannel({ reason })), ), }) +/** Legacy fuzzy `matchRoute` on `/$orgSlug/chat/$id` or `/$orgSlug/channels/$channelId/settings`. */ +const isChannelRoute = AppRoute.isAnyOf([ + "ChatChannel", + "ChatFiles", + "ChatFilesMedia", + "ChannelSettings", + "ChannelSettingsOverview", + "ChannelSettingsIntegrations", + "ChannelSettingsConnect", +]) + +const isOnChannel = (route: AppRoute, channelId: ChannelId) => isChannelRoute(route) && route.channelId === channelId + type Return = ModalReturn<Model, Message> -const update = (model: Model, message: Message, shared: Shared): Return => +const update = (model: Model, message: Message, context: ModalContext): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => - isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }, + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ClickedCancel: () => ({ model, outMessage: closed }), - ClickedDelete: () => ({ - model, - commands: [DeleteChannel({ channelId: model.channelId, orgSlug: shared.orgSlug })], - }), - SucceededDeleteChannel: ({ isOnDeletedChannel }) => ({ + ClickedDelete: () => ({ model, commands: [DeleteChannel({ channelId: model.channelId })] }), + // Leave the deleted channel's pages for the chat index (the route is read when the result lands). + SucceededDeleteChannel: () => ({ model, outMessage: completed({ - ...(isOnDeletedChannel && shared.orgSlug !== null - ? { href: hrefOf(AppRoute.ChatIndex({ orgSlug: shared.orgSlug })) } + ...(context.orgSlug !== null && isOnChannel(context.route, model.channelId) + ? { href: hrefOf(AppRoute.ChatIndex({ orgSlug: context.orgSlug })) } : {}), toast: successToast("Channel deleted successfully"), }), }), - FailedDeleteChannel: ({ toast }) => ({ + FailedDeleteChannel: ({ reason }) => ({ model, - outMessage: ModalOutMessage.RequestedToast({ toast }), + outMessage: ModalOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { title: "Channel not found", description: "This channel may have already been deleted." }, + }), + ), + }), }), }) diff --git a/apps/web-foldkit/src/overlay/modal/delete-message.ts b/apps/web-foldkit/src/overlay/modal/delete-message.ts index 6ee62ba29..c239e2f9f 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-message.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-message.ts @@ -3,35 +3,47 @@ import { Effect, Schema } from "effect" import { Command, Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { deleteMessageAction } from "~/db/actions" +import { CommonReason, failureMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf } from "../../data/actions" import { IconWarning } from "../../icons" import { button } from "../../ui/button" import { dialogFooter, dialogHeader } from "../../ui/dialog" -import { closed } from "../out-message" +import { closed, ModalOutMessage } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" -/** `components/chat/delete-message-modal.tsx`; confirming runs the toolbar's `deleteMessageAction`. */ +/** + * `components/chat/delete-message-modal.tsx`; confirming runs the toolbar's `deleteMessageAction`. + * The modal closes at once, so a failure arrives at the closed instance and still toasts. + */ const Model = Schema.Struct({ messageId: MessageId, frame: Frame }) type Model = typeof Model.Type -const Message = defineMessageUnion({ +/** What can go wrong deleting a message, beyond the shared reasons. */ +export const DeleteMessageFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type DeleteMessageFailure = typeof DeleteMessageFailure.Type + +export const Message = defineMessageUnion({ GotFrameMessage: { message: FrameMessage }, ClickedCancel: {}, ClickedDelete: {}, - CompletedDeleteMessage: {}, + SucceededDeleteMessage: {}, + FailedDeleteMessage: { reason: DeleteMessageFailure }, }) type Message = typeof Message.Type -const DeleteMessage = Command.define("DeleteMessage", { +export const DeleteMessage = Command.define("DeleteMessage", { args: { messageId: MessageId }, - messages: [Message.CompletedDeleteMessage], - // Legacy `handleDelete` fires the action without a toast either way. + messages: [Message.SucceededDeleteMessage, Message.FailedDeleteMessage], execute: ({ messageId }) => deleteMessageAction({ messageId }).pipe( - Effect.ignoreCause, - Effect.as(Message.CompletedDeleteMessage()), + Effect.as(Message.SucceededDeleteMessage()), + Effect.catchTags({ + MessageNotFoundError: () => Effect.succeed(Message.FailedDeleteMessage({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedDeleteMessage({ reason })), ), }) @@ -39,11 +51,23 @@ type Return = ModalReturn<Model, Message> const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ClickedCancel: () => ({ model, outMessage: closed }), // Legacy `handleDelete`: fire the action and close at once. ClickedDelete: () => ({ model, commands: [DeleteMessage({ messageId: model.messageId })], outMessage: closed }), - CompletedDeleteMessage: () => ({ model }), + // The optimistic delete already removed it; legacy shows no success toast. + SucceededDeleteMessage: () => ({ model }), + // The optimistic delete rolled back: say why the message came back. + FailedDeleteMessage: ({ reason }) => ({ + model, + outMessage: ModalOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { title: "Message not found", description: "It may have already been deleted." }, + }), + ), + }), + }), }) const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _inputs, h) => diff --git a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts index 7aa239e74..3f033a1a9 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts @@ -13,7 +13,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/delete-workspace-modal.tsx`; on success the settings page navigated to `/`. */ @@ -74,7 +74,7 @@ const isConfirmed = (model: Model) => model.confirmationText === model.organizat const update = (model: Model, message: Message): Return => Message.match<Return>(message, { GotFrameMessage: ({ message }) => - isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }, + isCloseRequest(message) ? { model, outMessage: closed } : { model }, ChangedConfirmationText: ({ value }) => ({ model: modifyFields(model, { confirmationText: () => value }), }), diff --git a/apps/web-foldkit/src/overlay/modal/edit-bot.ts b/apps/web-foldkit/src/overlay/modal/edit-bot.ts index fe7e86b68..68e09e70e 100644 --- a/apps/web-foldkit/src/overlay/modal/edit-bot.ts +++ b/apps/web-foldkit/src/overlay/modal/edit-bot.ts @@ -24,7 +24,7 @@ import { } from "./bot-form" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** @@ -110,7 +110,7 @@ const submitted = (model: Model): Return => { const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ChangedName: ({ value }) => ({ model: changedName(model, value) }), ChangedDescription: ({ value }) => ({ model: changedDescription(model, value) }), ToggledScope: ({ scope }) => ({ model: toggledScope(model, scope) }), diff --git a/apps/web-foldkit/src/overlay/modal/email-invite.ts b/apps/web-foldkit/src/overlay/modal/email-invite.ts index 64c8dc7ce..015080a29 100644 --- a/apps/web-foldkit/src/overlay/modal/email-invite.ts +++ b/apps/web-foldkit/src/overlay/modal/email-invite.ts @@ -14,7 +14,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { errorToast, successToast, warningToast } from "../../data/actions" import { sendInvitations } from "../../data/invitations" import { OrganizationId } from "@hazel/schema" @@ -125,7 +125,7 @@ const completedSendInvites = (model: Model, successCount: number, errorCount: nu const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), GotRoleMessage: ({ rowId, message }) => { const row = model.invites.find((candidate) => candidate.rowId === rowId) if (row === undefined) return { model } diff --git a/apps/web-foldkit/src/overlay/modal/feedback.ts b/apps/web-foldkit/src/overlay/modal/feedback.ts index 9ed7d5dbe..f1b1aeef7 100644 --- a/apps/web-foldkit/src/overlay/modal/feedback.ts +++ b/apps/web-foldkit/src/overlay/modal/feedback.ts @@ -10,7 +10,7 @@ import { textField } from "../../ui/text-field" import { closed, completed } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { successToast } from "../../data/actions" /** @@ -46,7 +46,7 @@ type Return = ModalReturn<Model, Message> const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), GotCategoryMessage: ({ message }) => { const result = Select.update(model.category, message) // The form-level validator reruns on any field change. diff --git a/apps/web-foldkit/src/overlay/modal/frame.ts b/apps/web-foldkit/src/overlay/modal/frame.ts index a63980ec6..c917a2746 100644 --- a/apps/web-foldkit/src/overlay/modal/frame.ts +++ b/apps/web-foldkit/src/overlay/modal/frame.ts @@ -1,3 +1,4 @@ +import { Schema } from "effect" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import type { ModalSize } from "~/components/ui/modal.styles" import { dialogTitle } from "../../ui/dialog" @@ -6,20 +7,38 @@ import * as KitModal from "../../ui/modal" /** * Legacy controlled `<Modal isOpen onOpenChange><ModalContent>`: the kit Modal without a trigger. - * A modal keeps a `frame` (kit Modal Model, always open) and closes when the frame reports closed. + * A modal is open for as long as its slot exists, so its `frame` is only the dialog's id; the modal + * closes itself when the frame asks to (`isCloseRequest`). */ -export const Frame = KitModal.Model -export type Frame = KitModal.Model +export const Frame = Schema.Struct({ id: Schema.String }) +export type Frame = typeof Frame.Type /** The frame's Message; modals import it `as FrameMessage` so their Got wrapper names the child Message. */ export const Message = KitModal.Message export type Message = KitModal.Message type FrameMessage = KitModal.Message -export const initFrame = (id: string): Frame => ({ id, isOpen: true }) +export const initFrame = (id: string): Frame => ({ id }) -/** True once the frame's Dismiss, close icon, Escape or outside press closed it. */ -export const isFrameClosed = (frame: Frame, message: FrameMessage) => !KitModal.update(frame, message).model.isOpen +/** The frame's Dismiss, close icon, Escape or outside press. */ +export const isCloseRequest = (message: FrameMessage): boolean => + KitModal.Message.match<boolean>(message, { + ClickedTrigger: () => false, + ClickedClose: () => true, + PressedEscape: () => true, + PressedOutside: () => true, + CompletedPortalModal: () => false, + }) + +/** The frame has portaled and moved focus into its dialog: the moment for a field's `autoFocus`. */ +export const isPortalReady = (message: FrameMessage): boolean => + KitModal.Message.match<boolean>(message, { + ClickedTrigger: () => false, + ClickedClose: () => false, + PressedEscape: () => false, + PressedOutside: () => false, + CompletedPortalModal: () => true, + }) export interface FrameOptions { readonly size?: ModalSize @@ -38,7 +57,7 @@ export const frameView = <Message>( ): Html => h.submodel({ slotId: frame.id, - model: frame, + model: KitModal.opened(KitModal.init(frame.id)), view: KitModal.view, viewInputs: { toTrigger: (_attributes, overlay) => overlay, toContent, ...options }, toParentMessage, diff --git a/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts b/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts index 1cecc7663..a1ed301c4 100644 --- a/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts +++ b/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts @@ -13,7 +13,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** The "Install by ID" modal of `routes/_app/$orgSlug/settings/integrations/installed.tsx`. */ @@ -108,7 +108,7 @@ const submitted = (model: Model): Return => { export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { GotFrameMessage: ({ message }) => - isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }, + isCloseRequest(message) ? { model, outMessage: closed } : { model }, ChangedBotId: ({ value }) => ({ model: modifyFields(model, { installBotId: () => value, installError: () => null }), }), diff --git a/apps/web-foldkit/src/overlay/modal/join-channel.ts b/apps/web-foldkit/src/overlay/modal/join-channel.ts index 15ea936e3..613019004 100644 --- a/apps/web-foldkit/src/overlay/modal/join-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/join-channel.ts @@ -17,7 +17,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, isPortalReady, modalDescription, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/join-channel-modal.tsx` (legacy `useModal("join-channel")`). */ @@ -83,9 +83,9 @@ const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { // `autoFocus`: after the frame has portaled and focused its dialog. GotFrameMessage: ({ message }) => - isFrameClosed(model.frame, message) + isCloseRequest(message) ? { model, outMessage: closed } - : message._tag === "CompletedPortalModal" + : isPortalReady(message) ? { model, commands: [FocusSearch()] } : { model }, ChangedSearch: ({ value }) => ({ model: modifyFields(model, { searchQuery: () => value }) }), diff --git a/apps/web-foldkit/src/overlay/modal/new-channel.ts b/apps/web-foldkit/src/overlay/modal/new-channel.ts index 1c821a031..576449804 100644 --- a/apps/web-foldkit/src/overlay/modal/new-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/new-channel.ts @@ -19,7 +19,7 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-channel-modal.tsx` (legacy `useModal("new-channel")`). */ @@ -94,7 +94,7 @@ const submitted = (model: Model, shared: Shared): Return => { const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), GotChannelTypeMessage: ({ message }) => { const result = Select.update(model.channelType, message) return { diff --git a/apps/web-foldkit/src/overlay/modal/regenerate-bot-token.ts b/apps/web-foldkit/src/overlay/modal/regenerate-bot-token.ts index 2138df918..a77a316da 100644 --- a/apps/web-foldkit/src/overlay/modal/regenerate-bot-token.ts +++ b/apps/web-foldkit/src/overlay/modal/regenerate-bot-token.ts @@ -11,7 +11,7 @@ import { ToastRequest } from "../toasts" import { botTokenDisplay } from "./bot-token-display" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import { errorToast, failureToast, successToast } from "../../data/actions" /** The regenerate-confirm modal of `components/bots/bot-card.tsx` (`bot.regenerateToken`). */ @@ -84,7 +84,7 @@ const toast = (request: ToastRequest) => ModalOutMessage.RequestedToast({ toast: const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), ClickedCancel: () => ({ model, outMessage: closed }), ClickedDone: () => ({ model, outMessage: closed }), ClickedRegenerate: () => diff --git a/apps/web-foldkit/src/overlay/modal/rename-channel.ts b/apps/web-foldkit/src/overlay/modal/rename-channel.ts index c058fc201..45a14b4f5 100644 --- a/apps/web-foldkit/src/overlay/modal/rename-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/rename-channel.ts @@ -7,22 +7,22 @@ import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { updateChannelAction } from "~/db/actions" import { channelCollection } from "~/db/collections" -import type { UserErrorMessage } from "~/lib/error-messages" +import { CommonReason, failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../data/actions" import { liveQueryStream } from "../../data/live-query" import { button } from "../../ui/button" import { dialogBody, dialogFooter, dialogHeader } from "../../ui/dialog" import { textField, textFieldIds } from "../../ui/text-field" import { closed, completed, ModalOutMessage } from "../out-message" -import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput, type ModalViewInputs } from "./contract" -import { failureToast, successToast } from "../../data/actions" import { Frame, Message as FrameMessage, frameView, initFrame, - isFrameClosed, + isCloseRequest, + isPortalReady, modalDescription, modalTitle, } from "./frame" @@ -46,6 +46,10 @@ export const Model = Schema.Struct({ }) type Model = typeof Model.Type +/** What can go wrong renaming, beyond the shared reasons. */ +export const RenameChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type RenameChannelFailure = typeof RenameChannelFailure.Type + export const Message = defineMessageUnion({ GotFrameMessage: { message: FrameMessage }, UpdatedChannel: { channel: Schema.NullOr(ChannelRow) }, @@ -53,7 +57,7 @@ export const Message = defineMessageUnion({ ClickedCancel: {}, SubmittedForm: {}, SucceededRenameChannel: {}, - FailedRenameChannel: { toast: ToastRequest }, + FailedRenameChannel: { reason: RenameChannelFailure }, CompletedFocusInput: {}, }) type Message = typeof Message.Type @@ -68,34 +72,20 @@ export interface RenameCopy { readonly notFound: UserErrorMessage } -const RenameChannel = Command.define("RenameChannel", { - args: { - channelId: ChannelId, - name: Schema.String, - notFoundTitle: Schema.String, - notFoundDescription: Schema.String, - }, +export const RenameChannel = Command.define("RenameChannel", { + args: { channelId: ChannelId, name: Schema.String }, messages: [Message.SucceededRenameChannel, Message.FailedRenameChannel], - execute: ({ channelId, name, notFoundTitle, notFoundDescription }) => + execute: ({ channelId, name }) => updateChannelAction({ channelId, name }).pipe( Effect.as(Message.SucceededRenameChannel()), - Effect.catchCause((cause) => - Effect.succeed( - Message.FailedRenameChannel({ - toast: failureToast(cause, "friendly", { - ChannelNotFoundError: { - title: notFoundTitle, - description: notFoundDescription, - isRetryable: false, - }, - }), - }), - ), - ), + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(Message.FailedRenameChannel({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedRenameChannel({ reason })), ), }) -/** The Input's `autoFocus`, once the frame has moved focus into the dialog. */ +/** The Input's `autoFocus`, once the frame has moved focus into the dialog. Best effort, like `autoFocus`. */ const FocusInput = Command.define("FocusInput", { args: { selector: Schema.String }, messages: [Message.CompletedFocusInput], @@ -107,20 +97,13 @@ type Return = ModalReturn<Model, Message> const inputId = (copy: RenameCopy) => textFieldIds(`${copy.id}-name`).input -const submitted = (model: Model, copy: RenameCopy): Return => { +const submitted = (model: Model): Return => { if (model.channel === null || model.isSubmitting) return { model } const trimmedName = model.name.trim() if (trimmedName === model.channel.name) return { model, outMessage: closed } return { model: modifyFields(model, { isSubmitting: () => true }), - commands: [ - RenameChannel({ - channelId: model.channel.id, - name: trimmedName, - notFoundTitle: copy.notFound.title, - notFoundDescription: copy.notFound.description ?? "", - }), - ], + commands: [RenameChannel({ channelId: model.channel.id, name: trimmedName })], } } @@ -129,8 +112,8 @@ const makeUpdate = (model: Model, message: Message): Return => Message.match<Return>(message, { GotFrameMessage: ({ message }) => { - if (isFrameClosed(model.frame, message)) return { model, outMessage: closed } - return message._tag === "CompletedPortalModal" + if (isCloseRequest(message)) return { model, outMessage: closed } + return isPortalReady(message) ? { model, commands: [FocusInput({ selector: `#${inputId(copy)}` })] } : { model } }, @@ -144,14 +127,16 @@ const makeUpdate = model: modifyFields(model, { name: () => value, isTouched: () => true }), }), ClickedCancel: () => ({ model, outMessage: closed }), - SubmittedForm: () => submitted(model, copy), + SubmittedForm: () => submitted(model), SucceededRenameChannel: () => ({ model, outMessage: completed({ toast: successToast(copy.successMessage) }), }), - FailedRenameChannel: ({ toast }) => ({ + FailedRenameChannel: ({ reason }) => ({ model: modifyFields(model, { isSubmitting: () => false }), - outMessage: ModalOutMessage.RequestedToast({ toast }), + outMessage: ModalOutMessage.RequestedToast({ + toast: errorToastOf(failureMessage(reason, { NotFound: copy.notFound })), + }), }), CompletedFocusInput: () => ({ model }), }) diff --git a/apps/web-foldkit/src/overlay/modal/rename-thread.ts b/apps/web-foldkit/src/overlay/modal/rename-thread.ts index 6ed1bd03e..047b43301 100644 --- a/apps/web-foldkit/src/overlay/modal/rename-thread.ts +++ b/apps/web-foldkit/src/overlay/modal/rename-thread.ts @@ -1,4 +1,3 @@ -import { ChannelId } from "@hazel/schema" import * as Requests from "./requests" import { defineModal } from "./contract" import { Message, Model, renameModalSpec } from "./rename-channel" diff --git a/apps/web-foldkit/src/overlay/modal/request-integration.ts b/apps/web-foldkit/src/overlay/modal/request-integration.ts index 992576df8..49e68ec92 100644 --- a/apps/web-foldkit/src/overlay/modal/request-integration.ts +++ b/apps/web-foldkit/src/overlay/modal/request-integration.ts @@ -12,7 +12,7 @@ import { textField } from "../../ui/text-field" import { closed, completed, ModalOutMessage } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isFrameClosed, modalDescription, modalTitle } from "./frame" +import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" import { errorToast, successToast } from "../../data/actions" /** `components/modals/request-integration-modal.tsx` (`integrationRequest.create`). */ @@ -92,7 +92,7 @@ const submitted = (model: Model, shared: Shared): Return => { const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), // tanstack-form validates the whole form schema on each change. ChangedIntegrationName: ({ value }) => ({ model: validated(modifyFields(model, { integrationName: () => value })), diff --git a/apps/web-foldkit/src/overlay/modal/set-status.ts b/apps/web-foldkit/src/overlay/modal/set-status.ts index 95bbc01c3..97656d07d 100644 --- a/apps/web-foldkit/src/overlay/modal/set-status.ts +++ b/apps/web-foldkit/src/overlay/modal/set-status.ts @@ -15,7 +15,7 @@ import * as Select from "../../ui/select" import { closed, completed, ModalOutMessage } from "../out-message" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalSubscriptionInput } from "./contract" -import { initFrame, isFrameClosed } from "./frame" +import { initFrame, isCloseRequest } from "./frame" import { customDateOf, EXPIRATION_OPTIONS, @@ -141,7 +141,7 @@ const presenceUpdated = (model: Model, presence: Presence | null): Return => ({ const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isFrameClosed(model.frame, message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), GotExpirationMessage: ({ message }) => expirationChanged(model, Select.update(model.expiration, message), shared), GotCustomDateMessage: ({ message }) => customDateChanged(model, message), GotEmojiPickerMessage: ({ message }) => emojiPickerChanged(model, message), From a5593f328c5fb94c366a89ddfd52a7b4c45adeb8 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:27:06 +0200 Subject: [PATCH 37/52] fix(web): composer draft owned by the Model, interruptible uploads, EditorRegistry - The draft markdown lives in the composer Model and mounts the editor as initialMarkdown, so edit mode survives the Files tab round-trip. - Uploads PUT through Effect.callback with an abort finalizer; stopping the stream marks the attachment cancelled. Outcomes are Succeeded/FailedUpload({ reason }). - Live editors live in an EditorRegistry Resource; editor Commands fail with EditorNotMounted instead of silently completing. - Bot commands: catchTags, Succeeded/Failed pair and an in-flight guard. - composeMode Plain|Replying|Editing, DragState, one accepted-file-types predicate, combobox aria-controls/activedescendant, no dead DropZone button, global typing gated on Model state (Shared.isOverlayOpen), branded IDs, Dom/Render helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/app/model.ts | 1 + .../src/composer/autocomplete-view.ts | 42 +- .../src/composer/composer.story.test.ts | 123 ++++- apps/web-foldkit/src/composer/composer.ts | 202 +++++--- apps/web-foldkit/src/composer/data.ts | 47 +- apps/web-foldkit/src/composer/draft-update.ts | 461 +++++++++++------- apps/web-foldkit/src/composer/draft-view.ts | 104 ++-- .../src/composer/draft.story.test.ts | 441 ++++++++++++++--- apps/web-foldkit/src/composer/draft.ts | 178 ++++--- apps/web-foldkit/src/composer/drop.ts | 58 +-- .../src/composer/editor-commands.ts | 96 ++-- .../web-foldkit/src/composer/trigger-views.ts | 81 ++- apps/web-foldkit/src/composer/update.ts | 260 ++++++---- apps/web-foldkit/src/composer/upload.test.ts | 128 +++++ apps/web-foldkit/src/composer/upload.ts | 197 +++++--- .../src/composer/view.scene.test.ts | 64 +++ apps/web-foldkit/src/composer/view.ts | 81 +-- .../web-foldkit/src/composer/window-events.ts | 72 ++- apps/web-foldkit/src/editor/behavior.ts | 32 +- apps/web-foldkit/src/editor/editor-view.ts | 62 ++- apps/web-foldkit/src/editor/file-types.ts | 35 ++ apps/web-foldkit/src/editor/operations.ts | 38 +- apps/web-foldkit/src/editor/plugins.ts | 10 +- apps/web-foldkit/src/editor/registry.ts | 57 +++ apps/web-foldkit/src/gallery/define.ts | 16 +- .../src/gallery/entries/composer.ts | 12 +- .../page/chat/channel/messages.scene.test.ts | 12 +- .../src/page/chat/channel/model.ts | 8 +- .../src/page/chat/channel/send.story.test.ts | 24 +- .../src/page/chat/channel/subscription.ts | 26 +- .../src/page/chat/channel/write.ts | 4 +- .../src/page/chat/composer-view.ts | 4 +- apps/web-foldkit/src/page/contract.ts | 2 + apps/web-foldkit/src/page/test-shared.ts | 1 + apps/web-foldkit/src/rpc.ts | 4 +- apps/web-foldkit/src/test/chat-fixtures.ts | 4 +- 36 files changed, 2109 insertions(+), 878 deletions(-) create mode 100644 apps/web-foldkit/src/composer/upload.test.ts create mode 100644 apps/web-foldkit/src/composer/view.scene.test.ts create mode 100644 apps/web-foldkit/src/editor/file-types.ts create mode 100644 apps/web-foldkit/src/editor/registry.ts diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index 215174e8e..fc20a936e 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -73,6 +73,7 @@ export const sharedOf = (model: Model): Shared => ({ 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) }) 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, option: MentionOption): Html => ], ) +/** Pressing in the list keeps the focus (and the caret) in the editor. */ +const keepFocus = <M>(h: HtmlBuilder<M>, toMessage: (message: Message) => M) => + h.OnMount(Mount.mapMessage(PreventEditorBlur(), toMessage)) + /** `AutocompleteListBox`: the option rows, or the trigger's empty message. */ export const listBox = <M>( h: HtmlBuilder<M>, + 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 = <M>( const mentionContent = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: ) /** The popover above the editor while a trigger is open (nothing otherwise). */ -export const autocompletePopover = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => { +export const autocompletePopover = <M>( + h: HtmlBuilder<M>, + 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 = <M>(h: HtmlBuilder<M>, 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<Composer.MentionMember> = [ + { 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<EditorMessage>((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<ReturnType<typeof editorById>>) => 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 = <C, F>( + 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 = <Message>( - channelId: string, + channelId: ChannelId, toMessage: (members: ReadonlyArray<MentionMember>) => 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 = <Message>( ) /** Every presence row, as `useMentionOptions` reads it. */ -export const presenceStream = <Message>( - toMessage: (presence: ReadonlyArray<{ userId: string; status: PresenceStatus }>) => Message, -) => - liveQueryStream<{ userId: string; status: PresenceStatus }, Message>( +export const presenceStream = <Message>(toMessage: (presence: ReadonlyArray<Presence>) => Message) => + liveQueryStream<Presence, Message>( (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 = <Message>( - organizationId: string, + organizationId: OrganizationId, toMessage: (bots: ReadonlyArray<MentionableBot>) => 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 = <Message>( .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 = <Message>( ) 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 = <Message>( - organizationId: string, + organizationId: OrganizationId, toMessage: (commands: ReadonlyArray<BotCommand>) => Message, ) => liveQueryStream<BotCommandRow, Message>( @@ -111,7 +118,11 @@ export const botCommandsStream = <Message>( ) .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 = <Message>( /** `ComposerEditor`'s own membership query: the signed-in user's member row in a channel. */ export const ownMembershipStream = <Message>( - 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 92fd328dc..295b7f28c 100644 --- a/apps/web-foldkit/src/composer/draft-update.ts +++ b/apps/web-foldkit/src/composer/draft-update.ts @@ -1,84 +1,111 @@ import type { MessageId } from "@hazel/schema" -import { Match } from "effect" -import { Command, type Update } from "foldkit" -import { errorToast } from "../data/actions" +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, } 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<Model, Message, OutMessage, HazelRpc | HazelApiClient> - -const editorIdOf = (model: Model) => model.composer.editorId +type Requirements = HazelRpc | HazelApiClient | EditorRegistry +export type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, Requirements> +type Step = Update.Step<Model, Message, Requirements> +/** A result that raises nothing to the page. */ +type PlainReturn = Update.Return<Model, Message, Requirements> -const toComposerCommands = (commands: ReadonlyArray<Command.Command<Composer.Message>>) => - Command.mapMessages(commands, (message) => Message.GotComposerMessage({ message })) +// CHILDREN -const liftComposer = (model: Model, result: Update.Return<Composer.Model, Composer.Message>): 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<Command.Command<Message, never, HazelRpc | HazelApiClient>>): 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<typeof OutMessage.RequestedToast>[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 } @@ -86,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, @@ -122,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<Return>(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<Return>(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<File>, 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<Return>(), 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), ) @@ -183,109 +338,81 @@ const handleComposer = (model: Model, message: Composer.Message, context: Contex export const update = (model: Model, message: Message, context: Context): Return => Message.match<Return>(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<Return>(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 ?? []), ...(inserted.commands ?? [])] } - }, - 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..083da36fa 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,7 +24,9 @@ export interface ReplyPreview { export interface DraftViewInputs<M> { 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 = <M>(h: HtmlBuilder<M>, onPress: M, label?: string) => @@ -37,12 +39,18 @@ const removeButton = <M>(h: HtmlBuilder<M>, onPress: M, label?: string) => onPress, attributes: label === undefined ? [] : [h.Attribute("aria-label", label)], }, - [IconClose(h, { ...(label === undefined ? {} : { className: "size-3.5" }), attributes: { "data-slot": "icon" } })], + [ + IconClose(h, { + ...(label === undefined ? {} : { className: "size-3.5" }), + attributes: { "data-slot": "icon" }, + }), + ], ) /** `ComposerAttachmentPreviews`. */ const attachmentPreviews = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInputs<M>): 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<Html>) => h.keyed("div")(key, [h.Class(className)], children) const fileText = (name: string, detail: string) => @@ -58,8 +66,8 @@ const attachmentPreviews = <M>(h: HtmlBuilder<M>, 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 +82,30 @@ const attachmentPreviews = <M>(h: HtmlBuilder<M>, 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 })), + ), ], ) }), - ...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 +113,7 @@ const attachmentPreviews = <M>(h: HtmlBuilder<M>, 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 +126,14 @@ const attachmentPreviews = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInputs<M>): 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" : ""))], [ @@ -136,8 +152,8 @@ const replyIndicator = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInp /** `ComposerEditIndicator`. */ const editIndicator = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInputs<M>): 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" : ""))], [ @@ -153,27 +169,31 @@ const editIndicator = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInpu ) } -/** 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, model: Model, inputs: DraftViewInputs<M>): Html => { const { toMessage } = inputs @@ -189,9 +209,7 @@ export const draftView = <M>(h: HtmlBuilder<M>, 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<string, string>): Draft.Model => { + const inCommand = ( + values: Record<string, string>, + 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 58c72a31b..59bc50d84 100644 --- a/apps/web-foldkit/src/composer/draft.ts +++ b/apps/web-foldkit/src/composer/draft.ts @@ -9,18 +9,19 @@ import { } from "@hazel/schema" 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 { CommonReason } from "~/lib/error-messages" import { HazelApiClient } from "../rpc" import { ToastRequest } from "../overlay/toasts" -import { commonMessage } from "~/lib/error-messages" -import { catchCommon, errorToast, failureToast, 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 * as EmojiDialog from "../emoji-picker/dialog" import * as GifPicker from "../gif-picker/picker" -import { DropEvent } from "./drop" +import { DragState, DropEvent } from "./drop" import { UploadEvent } from "./upload" /** @@ -31,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`. */ @@ -64,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 }, @@ -109,15 +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 }, - 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 @@ -154,37 +182,39 @@ export const SendMessage = Command.define("SendMessage", { restoreContent: Schema.Boolean, }, messages: [Message.SucceededSendMessage, Message.FailedSendMessage], - execute: ({ restoreContent, ...variables }) => - Effect.matchCause(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(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. */ @@ -198,43 +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 failedBotCommand = (title: string, description?: string) => - Message.CompletedExecuteBotCommand({ succeeded: false, toast: errorToast(title, description ?? null) }) - /** 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 }) => - HazelApiClient.use((client) => + 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: BotId.make(botId), commandName }, + params: { orgId, botId, commandName }, payload: { channelId, arguments: [...args] }, }), ).pipe( - Effect.as( - Message.CompletedExecuteBotCommand({ succeeded: true, toast: successToast(`Executed /${commandName}`) }), - ), + Effect.as(Message.SucceededExecuteBotCommand({ commandName })), Effect.catchTags({ - BotNotFoundError: () => Effect.succeed(failedBotCommand(`Bot "${botName}" was not found`)), - BotNotInstalledError: () => Effect.succeed(failedBotCommand(`Bot "${botName}" is not installed`)), - BotCommandNotFoundError: () => Effect.succeed(failedBotCommand(`Command /${commandName} not found`)), - BotCommandExecutionError: () => - Effect.succeed(failedBotCommand(`/${commandName} failed`, "The bot reported an error running the command.")), - }), - catchCommon((reason) => { - const message = commonMessage(reason) - return failedBotCommand(message.title, message.description) + 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<DropEvent>((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<void>((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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, option: CommandOption, isFocused: ) } -export const commandTriggerContent = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => { +export const commandTriggerContent = <M>( + h: HtmlBuilder<M>, + 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 = <M>(h: HtmlBuilder<M>, 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 = <M>(h: HtmlBuilder<M>, model: Model, toMessage: (message: Message) => M): Html => +export const emojiTriggerContent = <M>( + h: HtmlBuilder<M>, + 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 = <M>( 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 = <M>( [ 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 = <M>( [ 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 = <M>( [ 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..d1de27fc7 100644 --- a/apps/web-foldkit/src/composer/update.ts +++ b/apps/web-foldkit/src/composer/update.ts @@ -1,112 +1,190 @@ 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<Model, Message> +export type Return = Update.Return<Model, Message, EditorRegistry> -/** 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<Return>(), + 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<Return>(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). @@ -117,16 +195,28 @@ export const update = (model: Model, message: Message): Return => 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<readonly [string, unknown]> = [] + +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 f593fecb1..bf6ec03e2 100644 --- a/apps/web-foldkit/src/composer/upload.ts +++ b/apps/web-foldkit/src/composer/upload.ts @@ -1,26 +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 { type UploadErrorType, uploadErrorMessages, uploadToStorage } from "~/lib/upload-to-storage" -import { ToastRequest } from "../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" import { HazelApiClient, HazelRpc } from "../rpc" -import { errorToast } from "../data/actions" /** * 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 @@ -31,79 +44,105 @@ export interface UploadArgs { readonly channelId: ChannelId } -const markFailed = (id: AttachmentId, reason: string) => - Effect.gen(function* () { - const client = yield* HazelRpc - yield* client("attachment.fail", { id, reason }) - }).pipe(Effect.ignore) +const STORAGE_TIMEOUT_MS = 120_000 -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( - 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, - }, - }), - ), - ) - 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."), - } - } - 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.") } +class StorageUploadError extends Schema.TaggedError<StorageUploadError>()("StorageUploadError", { + reason: Schema.Literals(["StorageNetwork", "StorageTimeout", "StorageServer"]), +}) {} + +/** The presigned PUT, with progress. Interrupting it aborts the request. */ +const putToStorage = (uploadUrl: string, file: File, onProgress: (percent: number) => void) => + Effect.callback<void, StorageUploadError>((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)) } - 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<UploadEvent, never, HazelRpc | HazelApiClient> => +/** `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<UploadEvent, never, HazelRpc | HazelApiClient> => Stream.callback<UploadEvent, never, HazelRpc | HazelApiClient>((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 })), - ), + 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..1e9107cda --- /dev/null +++ b/apps/web-foldkit/src/composer/view.scene.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { UserId } from "@hazel/schema" +import { Schema } from "effect" +import * as Scene from "foldkit/scene" +import { describe, test } from "vitest" +import * as Composer from "./composer" +import { update } from "./update" +import { view } 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 } +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.Model>({ ...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.Model>({ + ...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..fc175716a 100644 --- a/apps/web-foldkit/src/composer/view.ts +++ b/apps/web-foldkit/src/composer/view.ts @@ -1,23 +1,25 @@ import { Mount } from "foldkit" -import { type Attribute, type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" +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. */ export interface BoxExtras<M> { readonly toMessage: (message: Message) => M @@ -36,20 +38,29 @@ export interface BoxExtras<M> { export type PickerButton = (attributes: ReadonlyArray<ChildAttribute>, 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 = <M>(h: HtmlBuilder<M>, 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-rac", ""), + 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 +74,7 @@ const actions = <M>(h: HtmlBuilder<M>, extras: BoxExtras<M>): Array<Html> => [ 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 ?? []), ]), @@ -96,17 +107,6 @@ export const composerBoxView = <M>(h: HtmlBuilder<M>, model: Model, extras: BoxE h.div( [h.Class("relative"), h.Attribute("data-rac", ""), ...(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 +127,33 @@ export const composerBoxView = <M>(h: HtmlBuilder<M>, 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, ), ), @@ -156,4 +173,6 @@ export const composerBoxView = <M>(h: HtmlBuilder<M>, model: Model, extras: BoxE const identity = (message: Message) => message /** The bare composer (the gallery entry). */ -export const view = defineView<Model, Message>((model, h) => composerBoxView(h, model, { toMessage: identity })) +export const view = defineView<Model, Message>((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 = <Message>(toMessage: (key: string) => Message): Stream.Stream<Message> => - Stream.callback<Message>((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: Message): Stream.Stream<Message> => - Stream.callback<Message>((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/editor/behavior.ts b/apps/web-foldkit/src/editor/behavior.ts index 45d1ad28e..3ce17cb48 100644 --- a/apps/web-foldkit/src/editor/behavior.ts +++ b/apps/web-foldkit/src/editor/behavior.ts @@ -4,6 +4,7 @@ import { redo, undo } from "prosemirror-history" import type { Node as ProseMirrorNode } from "prosemirror-model" import { AllSelection, type EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state" import type { EditorView } from "prosemirror-view" +import { isAcceptedFile, isAcceptedMimeType } from "./file-types" import { blocksOf, isValueEmpty, serializeToMarkdown } from "./markdown" import { autocompleteKey, canActivateTrigger, DEFAULT_TRIGGERS, isAutocompleteOpen } from "./plugins" import { schema } from "./schema" @@ -65,7 +66,9 @@ const handleTextInput = (view: EditorView, from: number, to: number, text: strin const trigger = DEFAULT_TRIGGERS.find((candidate) => candidate.char === text) if (trigger && !isAutocompleteOpen(state) && canActivateTrigger(state, trigger, from)) { view.dispatch( - state.tr.insertText(text, from, to).setMeta(autocompleteKey, { trigger, search: "", from, optionCount: 0 }), + state.tr + .insertText(text, from, to) + .setMeta(autocompleteKey, { trigger, search: "", from, optionCount: 0, activeOptionId: null }), ) return true } @@ -87,40 +90,21 @@ const isAtStart = (state: EditorState) => state.selection.empty && state.selecti const isAtEnd = (state: EditorState) => state.selection.empty && state.selection.$from.parentOffset === state.selection.$from.parent.content.size -/** `withFilePaste`'s accepted types (`ACCEPTED_FILE_TYPES`). */ -const ACCEPTED_FILE_TYPES = [ - "image/*", - "video/*", - "audio/*", - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "text/plain", - "text/csv", -] - -const isFileTypeAccepted = (type: string) => - ACCEPTED_FILE_TYPES.some((accepted) => - accepted.endsWith("/*") ? type.startsWith(accepted.slice(0, -1)) : type === accepted, - ) - /** `withFilePaste`: copied files first, else image items (screenshots). */ const pastedFiles = (data: DataTransfer): ReadonlyArray<File> => { - const files = Array.from(data.files).filter((file) => isFileTypeAccepted(file.type)) + const files = Array.from(data.files).filter(isAcceptedFile) if (files.length > 0) return files return Array.from(data.items).flatMap((item) => { - const file = item.kind === "file" && isFileTypeAccepted(item.type) ? item.getAsFile() : null + const file = item.kind === "file" && isAcceptedMimeType(item.type) ? item.getAsFile() : null return file ? [file] : [] }) } const AUTOCOMPLETE_KEYS: ReadonlyArray<string> = ["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"] -export const behaviorPlugin = (emit: (event: BehaviorEvent) => void, isUploading: () => boolean) => { +/** Submitting while a file uploads is the host's call: it ignores `Submitted` then and the text stays. */ +export const behaviorPlugin = (emit: (event: BehaviorEvent) => void) => { const submit = (view: EditorView) => { - if (isUploading()) return // Legacy detects languages for unlabeled code blocks on submit. let tr = view.state.tr view.state.doc.forEach((node, offset) => { diff --git a/apps/web-foldkit/src/editor/editor-view.ts b/apps/web-foldkit/src/editor/editor-view.ts index fd73b5564..bb599eb1a 100644 --- a/apps/web-foldkit/src/editor/editor-view.ts +++ b/apps/web-foldkit/src/editor/editor-view.ts @@ -4,7 +4,14 @@ import { EditorState } from "prosemirror-state" import { EditorView } from "prosemirror-view" import { cx } from "~/utils/cx" import { type BehaviorEvent, behaviorPlugin } from "./behavior" -import { blocksOf, isValueEmpty, serializeToMarkdown, toDoc, createEmptyValue } from "./markdown" +import { + blocksOf, + createEmptyValue, + deserializeFromMarkdown, + isValueEmpty, + serializeToMarkdown, + toDoc, +} from "./markdown" import { type AutocompleteState, autocompleteKey, @@ -19,8 +26,8 @@ import { schema } from "./schema" /** * A vanilla ProseMirror editor configured as the legacy composer. Framework-free: the - * Foldkit Mount (`composer/mount.ts`) owns its lifetime and turns `EditorEvent`s into - * Messages. Commands reach a live editor through `editorById`. + * Foldkit Mount (`composer/composer.ts`) owns its lifetime and turns `EditorEvent`s into + * Messages. Commands reach a live editor through the `EditorRegistry` (`registry.ts`). */ export type EditorEvent = @@ -33,8 +40,11 @@ export type EditorEvent = export interface EditorOptions { readonly placeholder: string + /** The draft the Model holds when the editor mounts: a remount restores it. */ + readonly initialMarkdown: string + /** The autocomplete listbox's id (`aria-controls`). */ + readonly listboxId: string readonly emit: (event: EditorEvent) => void - readonly isUploading?: () => boolean } /** Legacy `Editable` className, composed with the same `cx` (tailwind-merge) arguments. */ @@ -68,11 +78,14 @@ const atomView = (node: ProseMirrorNode) => { const sameAutocomplete = (a: AutocompleteState, b: AutocompleteState) => a.trigger?.id === b.trigger?.id && a.search === b.search +const initialBlocks = (markdown: string) => + markdown.trim() === "" ? createEmptyValue() : deserializeFromMarkdown(markdown) + export const createComposerEditor = (element: HTMLElement, options: EditorOptions): EditorView => { const state = EditorState.create({ - doc: toDoc(createEmptyValue()), + doc: toDoc(initialBlocks(options.initialMarkdown)), plugins: [ - behaviorPlugin(options.emit, options.isUploading ?? (() => false)), + behaviorPlugin(options.emit), autocompletePlugin, placeholderPlugin(options.placeholder), markdownDecorationsPlugin, @@ -86,11 +99,18 @@ export const createComposerEditor = (element: HTMLElement, options: EditorOption nodeViews: { mention: atomView, "custom-emoji": atomView }, attributes: (current) => { const autocomplete = autocompleteKey.getState(current) ?? closedAutocomplete + const isExpanded = autocomplete.trigger !== null && autocomplete.optionCount > 0 + const activeOption: Record<string, string> = + isExpanded && autocomplete.activeOptionId !== null + ? { "aria-activedescendant": autocomplete.activeOptionId } + : {} return { class: EDITABLE_CLASS, role: "combobox", "aria-autocomplete": "list", - "aria-expanded": String(autocomplete.trigger !== null && autocomplete.optionCount > 0), + "aria-controls": options.listboxId, + "aria-expanded": String(isExpanded), + ...activeOption, "aria-haspopup": "listbox", "aria-multiline": "true", style: "position: relative; white-space: pre-wrap; overflow-wrap: break-word;", @@ -101,7 +121,11 @@ export const createComposerEditor = (element: HTMLElement, options: EditorOption view.updateState(view.state.apply(tr)) if (tr.docChanged) { const blocks = blocksOf(view.state.doc) - options.emit({ _tag: "ChangedDoc", markdown: serializeToMarkdown(blocks), isEmpty: isValueEmpty(blocks) }) + options.emit({ + _tag: "ChangedDoc", + markdown: serializeToMarkdown(blocks), + isEmpty: isValueEmpty(blocks), + }) } const next = autocompleteKey.getState(view.state) ?? closedAutocomplete if (!sameAutocomplete(previous, next)) { @@ -116,24 +140,24 @@ export const createComposerEditor = (element: HTMLElement, options: EditorOption return view } -const editors = new Map<string, EditorView>() - -export const registerEditor = (editorId: string, view: EditorView) => editors.set(editorId, view) -export const unregisterEditor = (editorId: string, view: EditorView) => { - if (editors.get(editorId) === view) editors.delete(editorId) -} -export const editorById = (editorId: string) => editors.get(editorId) - /** Legacy `handleSelectByIndex` for a mention option. */ export const insertMention = (view: EditorView, userId: string, displayName: string) => { insertAutocompleteResult(view, schema.nodes.mention.create({ userId, displayName })) view.focus() } -/** Tell the editor how many options the popover shows, so it only captures keys when there are some. */ -export const syncAutocompleteOptionCount = (view: EditorView, optionCount: number) => { +/** + * Tell the editor how many options the popover shows, so it only captures keys when there are some, + * and which one is highlighted (`aria-activedescendant`). + */ +export const syncAutocompleteOptions = ( + view: EditorView, + optionCount: number, + activeOptionId: string | null, +) => { const current = autocompleteKey.getState(view.state) ?? closedAutocomplete - if (current.optionCount !== optionCount) view.dispatch(view.state.tr.setMeta(autocompleteKey, { optionCount })) + if (current.optionCount !== optionCount || current.activeOptionId !== activeOptionId) + view.dispatch(view.state.tr.setMeta(autocompleteKey, { optionCount, activeOptionId })) } export const closeAutocomplete = (view: EditorView) => diff --git a/apps/web-foldkit/src/editor/file-types.ts b/apps/web-foldkit/src/editor/file-types.ts new file mode 100644 index 000000000..3ce6bce84 --- /dev/null +++ b/apps/web-foldkit/src/editor/file-types.ts @@ -0,0 +1,35 @@ +/** + * The one list of file types a message can carry, for the file input's `accept`, drag-and-drop, + * paste and the draft's own check of selected files. + */ + +const ACCEPTED_MIME_TYPES: ReadonlyArray<string> = [ + "image/*", + "video/*", + "audio/*", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/plain", + "text/csv", +] + +/** Some systems report no MIME type for these; the extension decides then. */ +const ACCEPTED_EXTENSIONS: ReadonlyArray<string> = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".txt", ".csv"] + +/** The file input's `accept` attribute. */ +export const ACCEPT_ATTRIBUTE = [...ACCEPTED_MIME_TYPES, ...ACCEPTED_EXTENSIONS].join(",") + +/** A MIME type (as `DataTransferItem.type` or `File.type` reports it) a message can carry. */ +export const isAcceptedMimeType = (type: string): boolean => + ACCEPTED_MIME_TYPES.some((accepted) => + accepted.endsWith("/*") ? type.startsWith(accepted.slice(0, -1)) : type === accepted, + ) + +/** A file a message can carry: by type, or by extension when the type is unknown. */ +export const isAcceptedFile = (file: { readonly name: string; readonly type: string }): boolean => + file.type === "" + ? ACCEPTED_EXTENSIONS.some((extension) => file.name.toLowerCase().endsWith(extension)) + : isAcceptedMimeType(file.type) diff --git a/apps/web-foldkit/src/editor/operations.ts b/apps/web-foldkit/src/editor/operations.ts index 09d10e8da..2e375ad42 100644 --- a/apps/web-foldkit/src/editor/operations.ts +++ b/apps/web-foldkit/src/editor/operations.ts @@ -10,28 +10,27 @@ import { schema } from "./schema" * `focusAndInsertText`, `insertCustomEmoji`) plus the autocomplete inserts, on a live view. */ -/** Legacy skips focusing when a dialog (an emoji picker, a modal) holds the focus. */ -const isDialogFocused = () => { - const dialog = document.querySelector('[role="dialog"]') - const active = document.activeElement - return dialog !== null && active !== null && dialog.contains(active) -} - const replaceDoc = (view: EditorView, blocks: ReadonlyArray<ProseMirrorNode>) => { const { state } = view - const tr = state.tr.replaceWith(0, state.doc.content.size, [...blocks]).setMeta(autocompleteKey, closedAutocomplete) + const tr = state.tr + .replaceWith(0, state.doc.content.size, [...blocks]) + .setMeta(autocompleteKey, closedAutocomplete) view.dispatch(tr.setSelection(Selection.atEnd(tr.doc))) } -const selectEnd = (view: EditorView) => view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc))) +const selectEnd = (view: EditorView) => + view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc))) /** `setContent(markdown)`: replace the document with the parsed markdown. */ -export const setContent = (view: EditorView, markdown: string) => replaceDoc(view, deserializeFromMarkdown(markdown)) +export const setContent = (view: EditorView, markdown: string) => + replaceDoc(view, deserializeFromMarkdown(markdown)) -/** `resetAndFocus`: empty document, autocomplete closed, caret at the start. */ +/** + * `resetAndFocus`: empty document, autocomplete closed, caret at the start. Every caller acts on + * input in the composer itself, so no picker or dialog holds the focus then (legacy checked the DOM). + */ export const clearContent = (view: EditorView) => { replaceDoc(view, createEmptyValue()) - if (isDialogFocused()) return view.focus() view.dispatch(view.state.tr.setSelection(Selection.atStart(view.state.doc))) } @@ -44,10 +43,11 @@ export const focusAtEnd = (view: EditorView) => { /** `focusAndInsertText`: text goes through the same input path as typing, so triggers open. */ export const focusAndInsertText = (view: EditorView, text: string) => { - if (isDialogFocused()) return focusAtEnd(view) const { from, to } = view.state.selection - const handled = view.someProp("handleTextInput", (handle) => handle(view, from, to, text, () => view.state.tr)) + const handled = view.someProp("handleTextInput", (handle) => + handle(view, from, to, text, () => view.state.tr), + ) if (!handled) view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView()) } @@ -60,7 +60,11 @@ export const insertCustomEmoji = (view: EditorView, name: string, imageUrl: stri } /** An emoji option from the `:` trigger: unicode text, or an inline custom emoji. */ -export const insertEmojiResult = (view: EditorView, emoji: string, custom: { name: string; imageUrl: string } | null) => { +export const insertEmojiResult = ( + view: EditorView, + emoji: string, + custom: { name: string; imageUrl: string } | null, +) => { insertAutocompleteResult(view, custom ? schema.nodes["custom-emoji"].create(custom) : emoji) view.focus() } @@ -69,5 +73,7 @@ export const insertEmojiResult = (view: EditorView, emoji: string, custom: { nam export const removeTriggerText = (view: EditorView) => { const { trigger, from } = autocompleteKey.getState(view.state) ?? closedAutocomplete if (!trigger) return - view.dispatch(view.state.tr.delete(from, view.state.selection.head).setMeta(autocompleteKey, closedAutocomplete)) + view.dispatch( + view.state.tr.delete(from, view.state.selection.head).setMeta(autocompleteKey, closedAutocomplete), + ) } diff --git a/apps/web-foldkit/src/editor/plugins.ts b/apps/web-foldkit/src/editor/plugins.ts index a9f147af4..a9cbab9fc 100644 --- a/apps/web-foldkit/src/editor/plugins.ts +++ b/apps/web-foldkit/src/editor/plugins.ts @@ -140,9 +140,17 @@ export interface AutocompleteState { readonly from: number /** Options the host currently shows; keys are only captured when there are some. */ readonly optionCount: number + /** The DOM id of the highlighted option (`aria-activedescendant`). */ + readonly activeOptionId: string | null } -export const closedAutocomplete: AutocompleteState = { trigger: null, search: "", from: 0, optionCount: 0 } +export const closedAutocomplete: AutocompleteState = { + trigger: null, + search: "", + from: 0, + optionCount: 0, + activeOptionId: null, +} export const autocompleteKey = new PluginKey<AutocompleteState>("autocomplete") diff --git a/apps/web-foldkit/src/editor/registry.ts b/apps/web-foldkit/src/editor/registry.ts new file mode 100644 index 000000000..b6aa50bf2 --- /dev/null +++ b/apps/web-foldkit/src/editor/registry.ts @@ -0,0 +1,57 @@ +import { Context, Effect, Layer, Schema, type Scope } from "effect" +import type { EditorView } from "prosemirror-view" + +/** + * The live ProseMirror editors by id. An editor's Mount registers it for the element's lifetime; + * Commands reach it through the `EditorRegistry` Resource and fail with `EditorNotMounted` when its + * element is not in the DOM (a tab switched away, a closed palette). + * + * Mount `execute` cannot require services, so the Mounts register into the one `editorRegistry` + * instance directly; `EditorRegistryLive` hands that same instance to Commands. + */ + +export class EditorNotMounted extends Schema.TaggedError<EditorNotMounted>()("EditorNotMounted", { + editorId: Schema.String, +}) {} + +export interface EditorRegistryShape { + /** Holds `view` under `editorId` until the scope closes (the Mount's lifetime). */ + readonly register: (editorId: string, view: EditorView) => Effect.Effect<void, never, Scope.Scope> + readonly get: (editorId: string) => Effect.Effect<EditorView, EditorNotMounted> +} + +const makeRegistry = (): EditorRegistryShape => { + const editors = new Map<string, EditorView>() + return { + register: (editorId, view) => + Effect.acquireRelease( + Effect.sync(() => { + editors.set(editorId, view) + }), + () => + Effect.sync(() => { + // A remounted editor may already have replaced this one. + if (editors.get(editorId) === view) editors.delete(editorId) + }), + ), + get: (editorId) => + Effect.suspend(() => { + const view = editors.get(editorId) + return view === undefined + ? Effect.fail(new EditorNotMounted({ editorId })) + : Effect.succeed(view) + }), + } +} + +export const editorRegistry: EditorRegistryShape = makeRegistry() + +export class EditorRegistry extends Context.Service<EditorRegistry, EditorRegistryShape>()( + "EditorRegistry", +) {} + +export const EditorRegistryLive = Layer.succeed(EditorRegistry, editorRegistry) + +/** Run `f` on the live editor `editorId`. */ +export const withEditor = <A>(editorId: string, f: (view: EditorView) => A) => + EditorRegistry.use((registry) => registry.get(editorId)).pipe(Effect.map(f)) diff --git a/apps/web-foldkit/src/gallery/define.ts b/apps/web-foldkit/src/gallery/define.ts index a8ef14dcd..894d8420b 100644 --- a/apps/web-foldkit/src/gallery/define.ts +++ b/apps/web-foldkit/src/gallery/define.ts @@ -1,8 +1,12 @@ -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Runtime } from "foldkit" -import { HazelRpc, HazelRpcLive } from "../rpc" +import { type EditorRegistry, EditorRegistryLive } from "../editor/registry" +import { type HazelRpc, HazelRpcLive } from "../rpc" import { applyTheme, defaultCustomization, resolveSystemTheme } from "../theme" +/** What a gallery program's Commands may use: the RPC client, and the live editors (the composer). */ +type GalleryResources = HazelRpc | EditorRegistry + /** * A gallery entry is a self-contained Foldkit program for one primitive, booted on * `/dev/gallery/<name>` instead of the main app. Closing over the program keeps each @@ -15,14 +19,18 @@ export interface GalleryEntry { export const defineGallery = <Model, Message extends { _tag: string }>( title: string, - program: Omit<Runtime.ElementConfig<Model, Message, HazelRpc>, "container" | "resources">, + program: Omit<Runtime.ElementConfig<Model, Message, GalleryResources>, "container" | "resources">, ): GalleryEntry => ({ title, start: (container) => { // Same theme effects as the app's ThemeProvider port; captures fix the color scheme, so once is enough. Effect.runSync(applyTheme(resolveSystemTheme(), defaultCustomization())) Runtime.run( - Runtime.makeElement<Model, Message, HazelRpc>({ ...program, container, resources: HazelRpcLive }), + Runtime.makeElement<Model, Message, GalleryResources>({ + ...program, + container, + resources: Layer.merge(HazelRpcLive, EditorRegistryLive), + }), ) }, }) diff --git a/apps/web-foldkit/src/gallery/entries/composer.ts b/apps/web-foldkit/src/gallery/entries/composer.ts index 2747f914e..8c89f62eb 100644 --- a/apps/web-foldkit/src/gallery/entries/composer.ts +++ b/apps/web-foldkit/src/gallery/entries/composer.ts @@ -1,3 +1,4 @@ +import { ChannelId } from "@hazel/schema" import { and, eq } from "@tanstack/db" import { Effect, Schema, Stream } from "effect" import { Command, Subscription, type Update } from "foldkit" @@ -9,6 +10,7 @@ import { mentionMembersStream, presenceStream } from "../../composer/data" import * as ComposerUpdate from "../../composer/update" import * as ComposerView from "../../composer/view" import { liveQueryStream } from "../../data/live-query" +import type { EditorRegistry } from "../../editor/registry" import { HazelRpc } from "../../rpc" import { defineGallery } from "../define" import { galleryFrame, gallerySection } from "../frame" @@ -19,7 +21,7 @@ import { galleryFrame, gallerySection } from "../frame" const Model = Schema.Struct({ organizationId: Schema.NullOr(Schema.String), - channelId: Schema.NullOr(Schema.String), + channelId: Schema.NullOr(ChannelId), composer: Composer.Model, }) type Model = typeof Model.Type @@ -29,7 +31,7 @@ type Model = typeof Model.Type const Message = defineMessageUnion({ SucceededFetchCurrentUser: { organizationId: Schema.NullOr(Schema.String) }, FailedFetchCurrentUser: { reason: Schema.String }, - UpdatedChannel: { channelId: Schema.NullOr(Schema.String) }, + UpdatedChannel: { channelId: Schema.NullOr(ChannelId) }, GotComposerMessage: { message: Composer.Message }, }) type Message = typeof Message.Type @@ -49,7 +51,7 @@ const FetchCurrentUser = Command.define("FetchCurrentUser", { // UPDATE -type Return = Update.Return<Model, Message, HazelRpc> +type Return = Update.Return<Model, Message, HazelRpc | EditorRegistry> const update = (model: Model, message: Message): Return => Message.match<Return>(message, { @@ -79,7 +81,7 @@ const subscriptions = Subscription.make<Model, Message>()((entry) => ({ dependenciesToStream: ({ organizationId }) => organizationId === null ? Stream.empty - : liveQueryStream<{ id: string }, Message>( + : liveQueryStream<{ id: ChannelId }, Message>( (q) => q .from({ channel: channelCollection }) @@ -95,7 +97,7 @@ const subscriptions = Subscription.make<Model, Message>()((entry) => ({ }, ), mentionMembers: entry( - { channelId: Schema.NullOr(Schema.String) }, + { channelId: Schema.NullOr(ChannelId) }, { modelToDependencies: (model) => ({ channelId: model.channelId }), dependenciesToStream: ({ channelId }) => diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 753617b72..008abbbba 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -8,7 +8,7 @@ import * as EditorCommands from "../../../composer/editor-commands" import { channelScene, derivedModel, mountedChannel, mountedToolbar, overlays, draft } from "../../../test/chat-fixtures" import * as Menu from "../../../ui/menu" import * as Draft from "../../../composer/draft" -import { DropEvent, TrackFileDrop } from "../../../composer/drop" +import { DragState, DropEvent, TrackFileDrop } from "../../../composer/drop" import { MountEditor } from "../../../composer/composer" import { Message } from "./model" import * as Picker from "../../../emoji-picker/picker" @@ -138,17 +138,17 @@ describe("threads", () => { Scene.expect(Scene.text("Creating thread...")).toBeAbsent(), Scene.Mount.resolveAll( [ - MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread..." }), + MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread...", initialMarkdown: "" }), threadDraft(Draft.Message.GotComposerMessage({ message: Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }) })), ], [ { name: "TrackFileDrop" }, - threadDraft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ isDraggingOnPage: false, isDropTarget: false }) })), + threadDraft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ drag: DragState.Idle() }) })), ], ), Scene.click(Scene.role("button", { name: "Close thread" })), Scene.expect(Scene.role("heading", { name: "Thread" })).toBeAbsent(), - Scene.Mount.expectEnded(MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread..." }), TrackFileDrop), + Scene.Mount.expectEnded(MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread...", initialMarkdown: "" }), TrackFileDrop), ) }) }) @@ -228,13 +228,13 @@ describe("composer emoji picker", () => { Scene.Mount.resolveAll( [{ name: "PortalPickerPopover" }, emojiDialog(EmojiDialog.Message.ReceivedPopoverEvent({ event: PopoverEvent.CompletedPortalPickerPopover() }))], [{ name: "MeasurePicker" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: measured }))], - [{ name: "TrackPickerKeys" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: Picker.Message.PressedNavigationKey({ key: "ArrowRight" }) }))], + [{ name: "TrackPickerFocusOut" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: Picker.Message.BlurredPicker() }))], ), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toExist(), Scene.expect(Scene.role("gridcell", { name: "party popper" })).toExist(), // The popover around the dialog owns the Escape key (React Aria's Popover). Scene.keydown(Scene.selector(`#${popoverId(`${editorId}-emoji-picker`)}`), "Escape"), - Scene.Mount.expectEnded({ name: "PortalPickerPopover" }, { name: "MeasurePicker" }, { name: "TrackPickerKeys" }), + Scene.Mount.expectEnded({ name: "PortalPickerPopover" }, { name: "MeasurePicker" }, { name: "TrackPickerFocusOut" }), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toBeAbsent(), Scene.Command.expectNone(), ) diff --git a/apps/web-foldkit/src/page/chat/channel/model.ts b/apps/web-foldkit/src/page/chat/channel/model.ts index 94d31e0b0..aa9556639 100644 --- a/apps/web-foldkit/src/page/chat/channel/model.ts +++ b/apps/web-foldkit/src/page/chat/channel/model.ts @@ -6,6 +6,7 @@ import * as Live from "../../../chat/live-state" import * as Unfurl from "../../../chat/unfurl" import * as Draft from "../../../composer/draft" import * as MessageList from "../../../mount/message-list" +import type { EditorRegistry } from "../../../editor/registry" import type { HazelApiClient, HazelRpc } from "../../../rpc" import type { PageOutMessage } from "../../out-message" import { @@ -137,5 +138,10 @@ export const Message = defineMessageUnion({ }) export type Message = typeof Message.Type -export type PageReturn = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc | HazelApiClient> +export type PageReturn = Update.ReturnWithOutMessage< + Model, + Message, + PageOutMessage, + HazelRpc | HazelApiClient | EditorRegistry +> diff --git a/apps/web-foldkit/src/page/chat/channel/send.story.test.ts b/apps/web-foldkit/src/page/chat/channel/send.story.test.ts index 88daa1705..9741a306f 100644 --- a/apps/web-foldkit/src/page/chat/channel/send.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/send.story.test.ts @@ -70,9 +70,9 @@ describe("send", () => { updateWithShared, given(loadedModel()), message(overlays(Overlays.Message.ClickedReply({ messageId: graceMessageId }))), - model((current) => expect(current.draft.replyToMessageId).toBe(graceMessageId)), + model((current) => expect(Draft.replyToMessageIdOf(current.draft)).toBe(graceMessageId)), message(composer(Composer.Message.SubmittedDraft({ markdown: "On it" }))), - model((current) => expect(current.draft.replyToMessageId).toBeNull()), + model((current) => expect(Draft.replyToMessageIdOf(current.draft)).toBeNull()), Command.expectHas( Draft.SendMessage({ channelId, @@ -105,11 +105,11 @@ describe("send", () => { restoreContent: true, replyToMessageId: graceMessageId, attachmentIds: [], - toast, + reason: "ChannelNotFound", }), ), expectOutMessage(PageOutMessage.RequestedToast({ toast })), - model((current) => expect(current.draft.replyToMessageId).toBe(graceMessageId)), + model((current) => expect(Draft.replyToMessageIdOf(current.draft)).toBe(graceMessageId)), Command.expectHas(EditorCommands.SetEditorContent({ editorId, markdown: "On it" })), Command.resolve(EditorCommands.SetEditorContent, Composer.Message.CompletedSetEditorContent()), ) @@ -143,17 +143,17 @@ describe("edit", () => { updateWithShared, given(loadedModel()), message(composer(Composer.Message.PressedArrowUpInEmpty())), - model((current) => expect(current.draft.editingMessageId).toBe(adaMessageId)), + model((current) => expect(Draft.editingMessageIdOf(current.draft)).toBe(adaMessageId)), Command.expectExact(EditorCommands.SetEditorContent({ editorId, markdown: "Shipped the onboarding copy" })), Command.resolve(EditorCommands.SetEditorContent, Composer.Message.CompletedSetEditorContent()), message(composer(Composer.Message.SubmittedDraft({ markdown: "Shipped the new onboarding copy" }))), - model((current) => expect(current.draft.editingMessageId).toBeNull()), + model((current) => expect(Draft.editingMessageIdOf(current.draft)).toBeNull()), Command.expectExact( EditorCommands.ClearEditor({ editorId }), Draft.EditMessage({ messageId: adaMessageId, content: "Shipped the new onboarding copy" }), ), Command.resolve(EditorCommands.ClearEditor, Composer.Message.CompletedClearEditor()), - Command.resolve(Draft.EditMessage, Draft.Message.CompletedEditMessage({ toast: null })), + Command.resolve(Draft.EditMessage, Draft.Message.SucceededEditMessage()), ) }) @@ -164,7 +164,7 @@ describe("edit", () => { message(overlays(Overlays.Message.ClickedEdit({ messageId: adaMessageId }))), Command.resolve(EditorCommands.SetEditorContent, Composer.Message.CompletedSetEditorContent()), message(composer(Composer.Message.PressedEscape())), - model((current) => expect(current.draft.editingMessageId).toBeNull()), + model((current) => expect(Draft.editingMessageIdOf(current.draft)).toBeNull()), Command.expectExact(EditorCommands.ClearEditor({ editorId })), Command.resolve(EditorCommands.ClearEditor, Composer.Message.CompletedClearEditor()), ) @@ -206,18 +206,18 @@ describe("uploads", () => { given(loadedModel()), message(draft(Draft.Message.SelectedFiles({ files: [file] }))), Command.resolve(Draft.GenerateUploadFileId, Draft.Message.CompletedGenerateUploadFileId({ fileId: "f1" })), - model((current) => expect(current.draft.uploadingFiles).toEqual([{ fileId: "f1", fileName: "diagram.png", fileSize: 3, progress: 0 }])), + model((current) => expect(current.draft.currentUpload).toEqual({ fileId: "f1", file, progress: 0 })), message(draft(Draft.Message.ReceivedUploadEvent({ event: UploadEvent.ProgressedUpload({ fileId: "f1", progress: 40 }) }))), - model((current) => expect(current.draft.uploadingFiles[0]?.progress).toBe(40)), + model((current) => expect(current.draft.currentUpload?.progress).toBe(40)), message( draft( Draft.Message.ReceivedUploadEvent({ - event: UploadEvent.FinishedUpload({ fileId: "f1", attachmentId, toast: null }), + event: UploadEvent.SucceededUpload({ fileId: "f1", attachmentId }), }), ), ), model((current) => { - expect(current.draft.uploadingFiles).toEqual([]) + expect(current.draft.currentUpload).toBeNull() expect(current.draft.attachmentIds).toEqual([attachmentId]) }), message(composer(Composer.Message.SubmittedDraft({ markdown: "" }))), diff --git a/apps/web-foldkit/src/page/chat/channel/subscription.ts b/apps/web-foldkit/src/page/chat/channel/subscription.ts index 5ffcc4b2d..989929ea1 100644 --- a/apps/web-foldkit/src/page/chat/channel/subscription.ts +++ b/apps/web-foldkit/src/page/chat/channel/subscription.ts @@ -1,4 +1,4 @@ -import { ChannelId, MessageId, OrganizationId, TypingIndicatorId } from "@hazel/schema" +import { ChannelId, MessageId, OrganizationId, TypingIndicatorId, UserId } from "@hazel/schema" import { Effect, Option, PubSub, Schema, Stream } from "effect" import { Subscription } from "foldkit" import * as Live from "../../../chat/live-state" @@ -231,7 +231,7 @@ const uploadEntry = (which: "channel" | "thread") => ({ modelToDependencies: ({ model }: Input) => { const draft = which === "channel" ? model.draft : model.threadDraft return { - upload: draft?.currentUpload ?? null, + upload: draft === null ? null : Draft.currentUploadOf(draft), channelId: draft?.channelId ?? null, organizationId: model.channel?.organizationId ?? null, } @@ -292,6 +292,17 @@ export const typingCleanupStream = <R>( ), ) +/** A dialog of the page holds the keyboard, so typed keys stay there (legacy checked the DOM for one). */ +const hasOpenDialog = (model: Model) => + model.overlays.imageViewer !== null || + model.overlays.reactionPicker !== null || + model.overlays.reactionModal !== null || + model.overlays.popover !== null || + model.overlays.pinned.isOpen || + [model.draft, model.threadDraft].some( + (draft) => draft !== null && (draft.emojiPicker.isOpen || draft.gifPicker.isOpen), + ) + /** The write path's streams: composer data, uploads, global typing and window blur. */ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((entry) => ({ composerMembers: entry(byChannel, { @@ -302,7 +313,7 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en ), }), composerBots: entry( - { organizationId: Schema.NullOr(Schema.String) }, + { organizationId: Schema.NullOr(OrganizationId) }, { modelToDependencies: ({ shared }) => ({ organizationId: shared.currentUser?.organizationId ?? null }), dependenciesToStream: ({ organizationId }) => @@ -314,7 +325,7 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en }, ), composerCommands: entry( - { organizationId: Schema.NullOr(Schema.String) }, + { organizationId: Schema.NullOr(OrganizationId) }, { modelToDependencies: ({ shared }) => ({ organizationId: shared.currentUser?.organizationId ?? null }), dependenciesToStream: ({ organizationId }) => @@ -326,7 +337,7 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en }, ), threadMembership: entry( - { threadChannelId: Schema.NullOr(ChannelId), userId: Schema.NullOr(Schema.String) }, + { threadChannelId: Schema.NullOr(ChannelId), userId: Schema.NullOr(UserId) }, { modelToDependencies: ({ model }) => ({ threadChannelId: model.overlays.thread?.threadChannelId ?? null, @@ -350,7 +361,10 @@ const write = Subscription.make<Input, Message, HazelRpc | HazelApiClient>()((en globalTyping: entry( { isActive: Schema.Boolean }, { - modelToDependencies: ({ model }) => ({ isActive: model.tab === "messages" && isMemberOf(model) !== false }), + modelToDependencies: ({ model, shared }) => ({ + isActive: + model.tab === "messages" && isMemberOf(model) !== false && !shared.isOverlayOpen && !hasOpenDialog(model), + }), dependenciesToStream: ({ isActive }) => isActive ? globalTypingStream((key) => Message.PressedGlobalKey({ key })) : Stream.empty, }, diff --git a/apps/web-foldkit/src/page/chat/channel/write.ts b/apps/web-foldkit/src/page/chat/channel/write.ts index 27d52a3cf..86aa697c8 100644 --- a/apps/web-foldkit/src/page/chat/channel/write.ts +++ b/apps/web-foldkit/src/page/chat/channel/write.ts @@ -1,4 +1,4 @@ -import { type ChannelId, type MessageId, PinnedMessageId } from "@hazel/schema" +import { type ChannelId, type MessageId, PinnedMessageId, type UserId } from "@hazel/schema" import { Match, Option, Schema } from "effect" import { Command } from "foldkit" import * as Composer from "../../../composer/composer" @@ -122,7 +122,7 @@ export const updateDraft = (model: Model, which: Which, message: Draft.Message, export const forwardComposerData = (model: Model, message: Composer.Message): PageReturn => updateDraft(model, "channel", Draft.Message.GotComposerMessage({ message }), null) -export const toComposerPresence = (presence: ReadonlyArray<{ readonly userId: string; readonly status: string }>) => +export const toComposerPresence = (presence: ReadonlyArray<{ readonly userId: UserId; readonly status: string }>) => presence.map((row) => ({ userId: row.userId, status: isPresenceStatus(row.status) ? row.status : ("offline" as const) })) const isPresenceStatus = Schema.is(Composer.PresenceStatus) diff --git a/apps/web-foldkit/src/page/chat/composer-view.ts b/apps/web-foldkit/src/page/chat/composer-view.ts index a6d99cf92..8fe6e1719 100644 --- a/apps/web-foldkit/src/page/chat/composer-view.ts +++ b/apps/web-foldkit/src/page/chat/composer-view.ts @@ -1,6 +1,6 @@ import type { AttachmentId } from "@hazel/schema" import type { Html, HtmlBuilder } from "foldkit/html" -import type * as Draft from "../../composer/draft" +import * as Draft from "../../composer/draft" import { draftView, type DraftViewInputs, type ReplyPreview } from "../../composer/draft-view" import { identityOf, toDeriveContext } from "./derive" import type { AttachmentInfo } from "./lookups" @@ -10,7 +10,7 @@ import type { Model } from "./channel/model" /** The reply a draft is composing. A fresh object each call: memoized views compare its fields. */ export const replyPreviewOf = (model: Model, draft: Draft.Model): ReplyPreview | null => { - const replyTo = draft.replyToMessageId + const replyTo = Draft.replyToMessageIdOf(draft) const message = replyTo === null ? undefined diff --git a/apps/web-foldkit/src/page/contract.ts b/apps/web-foldkit/src/page/contract.ts index 8397c000b..3b7959b7c 100644 --- a/apps/web-foldkit/src/page/contract.ts +++ b/apps/web-foldkit/src/page/contract.ts @@ -31,6 +31,8 @@ export interface Shared { readonly soundSettings: SoundSettings /** The desktop app's update check and install (always `Idle` on the web). */ readonly desktopUpdate: Updates.Model + /** A root modal or the command palette is open (it holds the keyboard; the composer's global typing waits). */ + readonly isOverlayOpen: boolean } /** `usePermission().can(action)` */ diff --git a/apps/web-foldkit/src/page/test-shared.ts b/apps/web-foldkit/src/page/test-shared.ts index c034b1b6a..163628c0f 100644 --- a/apps/web-foldkit/src/page/test-shared.ts +++ b/apps/web-foldkit/src/page/test-shared.ts @@ -8,4 +8,5 @@ export const sharedDefaults = { theme: { ...defaultThemePreference(), resolved: "light" }, soundSettings: DEFAULT_SOUND_SETTINGS, desktopUpdate: { _tag: "Idle" }, + isOverlayOpen: false, } satisfies Partial<Shared> diff --git a/apps/web-foldkit/src/rpc.ts b/apps/web-foldkit/src/rpc.ts index 391019160..59a4ac52f 100644 --- a/apps/web-foldkit/src/rpc.ts +++ b/apps/web-foldkit/src/rpc.ts @@ -31,6 +31,7 @@ import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-mid import { CustomFetchLive } from "~/lib/services/common/api-client" import { HazelAuthClient, HazelAuthClientLive } from "./auth/client" import { MessageWindows, MessageWindowsLive } from "./data/message-windows" +import { EditorRegistry, EditorRegistryLive } from "./editor/registry" /** Same groups, transport and auth middleware as the legacy `HazelRpcClient`, as a plain Effect service. */ const AllRpcs = MessageRpcs.merge( @@ -83,11 +84,12 @@ export const HazelApiClientLive = Layer.effect(HazelApiClient, makeApiClient).pi ) /** The app's `resources`: what every Command and Subscription may use. */ -export type Resources = HazelRpc | HazelApiClient | MessageWindows | HazelAuthClient +export type Resources = HazelRpc | HazelApiClient | MessageWindows | HazelAuthClient | EditorRegistry export const ResourcesLive = Layer.mergeAll( HazelRpcLive, HazelApiClientLive, MessageWindowsLive, HazelAuthClientLive, + EditorRegistryLive, ) diff --git a/apps/web-foldkit/src/test/chat-fixtures.ts b/apps/web-foldkit/src/test/chat-fixtures.ts index 24ea7398e..acfc633df 100644 --- a/apps/web-foldkit/src/test/chat-fixtures.ts +++ b/apps/web-foldkit/src/test/chat-fixtures.ts @@ -2,7 +2,7 @@ import * as Scene from "foldkit/scene" import type { Html, HtmlBuilder } from "foldkit/html" import * as Composer from "../composer/composer" import * as Draft from "../composer/draft" -import { DropEvent } from "../composer/drop" +import { DragState, DropEvent } from "../composer/drop" import * as MessageList from "../mount/message-list" import * as Overlays from "../page/chat/overlays" import * as Tooltip from "../ui/tooltip" @@ -48,7 +48,7 @@ export const mountedChannel = (hovered: MessageId | null = null): Step => [{ name: "MountEditor" }, composer(Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }))], [ { name: "TrackFileDrop" }, - draft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ isDraggingOnPage: false, isDropTarget: false }) })), + draft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ drag: DragState.Idle() }) })), ], [ { name: "TrackMessageHover" }, From f4d3e44d7f0047d1a1018af9d41e3d22ec87a738 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 16:29:58 +0200 Subject: [PATCH 38/52] refactor(web): onboarding as one Phase/StepState union with retryable finalization The page is Starting{membership, timezone} | Leaving | Running{flow, step, data}, and each step's form lives in its StepState variant (Profile, Timezone, UseCases, Role, Invite, Finalizing{failure}, Completed), replacing step + form + isInitialized + hasRedirected + error. A failed finalize stops the spinner and offers Try again (ClickedRetryFinalize). One validation module feeds update and the views (a whitespace name now disables Continue). Invite rows are {id, email, error} keyed by id with MAX_INVITES enforced in update. All four failures carry typed reasons built with catchTags + catchCommon; NoOrganization is decided in update. Back is ignored while a request is in flight, and late successes need a pending submit. Dead Data.emails is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/page/onboarding/command.ts | 98 ++-- .../src/page/onboarding/message.ts | 18 +- apps/web-foldkit/src/page/onboarding/model.ts | 116 ++++- .../src/page/onboarding/scene.test.ts | 53 ++- .../src/page/onboarding/steps/invite.ts | 103 ++-- .../src/page/onboarding/steps/profile.ts | 10 +- .../src/page/onboarding/story.test.ts | 362 +++++++++----- .../src/page/onboarding/timezone/view.ts | 4 +- .../src/page/onboarding/transition.ts | 128 ++--- .../web-foldkit/src/page/onboarding/update.ts | 447 ++++++++++-------- .../src/page/onboarding/validation.ts | 34 ++ apps/web-foldkit/src/page/onboarding/view.ts | 143 +++--- .../src/test/pages-entry-fixtures.ts | 15 +- 13 files changed, 953 insertions(+), 578 deletions(-) create mode 100644 apps/web-foldkit/src/page/onboarding/validation.ts diff --git a/apps/web-foldkit/src/page/onboarding/command.ts b/apps/web-foldkit/src/page/onboarding/command.ts index 053f2efdf..a430a4abb 100644 --- a/apps/web-foldkit/src/page/onboarding/command.ts +++ b/apps/web-foldkit/src/page/onboarding/command.ts @@ -1,18 +1,17 @@ import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" -import { Duration, Effect, Exit, Schema } from "effect" +import { Duration, Effect, Schema } from "effect" import { Command } from "foldkit" import { load, replaceUrl } from "foldkit/navigation" -import { failureToast } from "../../data/actions" -import { HazelRpc } from "../../rpc" +import { catchCommon } from "../../data/actions" import { sendInvitations } from "../../data/invitations" +import { HazelRpc } from "../../rpc" import { Message } from "./message" export const ReadBrowserTimezone = Command.define("ReadBrowserTimezone", { messages: [Message.DetectedBrowserTimezone], - execute: - Effect.sync(() => - Message.DetectedBrowserTimezone({ browserTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }), - ), + execute: Effect.sync(() => + Message.DetectedBrowserTimezone({ browserTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }), + ), }) /** `navigate({ search: (prev) => ({ ...prev, step }), replace: true })`, the href from `onboardingHref`. */ @@ -27,11 +26,13 @@ export const UpdateProfile = Command.define("UpdateProfile", { args: { userId: UserId, firstName: Schema.String, lastName: Schema.String }, messages: [Message.SucceededUpdateProfile, Message.FailedUpdateProfile], execute: ({ userId, firstName, lastName }) => - Effect.gen(function* () { - const client = yield* HazelRpc - yield* client("user.update", { id: userId, firstName, lastName }) - return Message.SucceededUpdateProfile() - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedUpdateProfile()))), + HazelRpc.use((client) => client("user.update", { id: userId, firstName, lastName })).pipe( + Effect.as(Message.SucceededUpdateProfile()), + Effect.catchTags({ + UserNotFoundError: () => Effect.succeed(Message.FailedUpdateProfile({ reason: "UserNotFound" })), + }), + catchCommon((reason) => Message.FailedUpdateProfile({ reason })), + ), }) export const DebounceTimezoneQuery = Command.define("DebounceTimezoneQuery", { @@ -45,38 +46,30 @@ export const UpdateUserTimezone = Command.define("UpdateUserTimezone", { args: { userId: UserId, timezone: Schema.String }, messages: [Message.SucceededUpdateTimezone, Message.FailedUpdateTimezone], execute: ({ userId, timezone }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("user.update", { id: userId, timezone })) - if (Exit.isSuccess(exit)) return Message.SucceededUpdateTimezone({ timezone }) - return Message.FailedUpdateTimezone({ - toast: failureToast(exit.cause, "friendly", { - UserNotFoundError: { - title: "User not found", - description: "Your account could not be found. Please try signing in again.", - }, - }), - }) - }), + HazelRpc.use((client) => client("user.update", { id: userId, timezone })).pipe( + Effect.as(Message.SucceededUpdateTimezone({ timezone })), + Effect.catchTags({ + UserNotFoundError: () => Effect.succeed(Message.FailedUpdateTimezone({ reason: "UserNotFound" })), + }), + catchCommon((reason) => Message.FailedUpdateTimezone({ reason })), + ), }) /** Invites each address to the user's organization as a member. */ export const SendInvites = Command.define("SendInvites", { - args: { organizationId: Schema.NullOr(OrganizationId), emails: Schema.Array(Schema.String) }, + args: { organizationId: OrganizationId, emails: Schema.Array(Schema.String) }, messages: [Message.SucceededSendInvites, Message.FailedSendInvites], execute: ({ organizationId, emails }) => - organizationId === null - ? Effect.succeed(Message.FailedSendInvites({ reason: "NoOrganization" })) - : sendInvitations( - organizationId, - emails.map((email) => ({ email, role: "member" as const })), - ).pipe( - Effect.map(({ errorCount }) => - errorCount === emails.length - ? Message.FailedSendInvites({ reason: "AllFailed" }) - : Message.SucceededSendInvites({ emails, failedCount: errorCount }), - ), - ), + sendInvitations( + organizationId, + emails.map((email) => ({ email, role: "member" as const })), + ).pipe( + Effect.map(({ errorCount }) => + errorCount === emails.length + ? Message.FailedSendInvites({ reason: "AllFailed" }) + : Message.SucceededSendInvites({ emails, failedCount: errorCount }), + ), + ), }) /** `handleFinalization`: finalize first (critical), then the best-effort metadata. */ @@ -88,18 +81,23 @@ export const CompleteOnboarding = Command.define("CompleteOnboarding", { }, messages: [Message.SucceededCompleteOnboarding, Message.FailedCompleteOnboarding], execute: ({ memberId, role, useCases }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const finalized = yield* Effect.exit(client("user.finalizeOnboarding", undefined)) - if (Exit.isFailure(finalized)) - return Message.FailedCompleteOnboarding({ error: "Failed to finalize onboarding" }) - if (memberId !== null) - yield* client("organizationMember.updateMetadata", { - id: memberId, - metadata: { ...(role === null ? {} : { role }), useCases }, - }).pipe(Effect.ignore) - return Message.SucceededCompleteOnboarding() - }), + HazelRpc.use((client) => client("user.finalizeOnboarding", undefined)).pipe( + Effect.andThen( + memberId === null + ? Effect.void + : HazelRpc.use((client) => + client("organizationMember.updateMetadata", { + id: memberId, + metadata: { ...(role === null ? {} : { role }), useCases }, + }), + ).pipe( + // Best effort: the answers are nice to have, and onboarding is already finished. + Effect.ignore, + ), + ), + Effect.as(Message.SucceededCompleteOnboarding()), + catchCommon((reason) => Message.FailedCompleteOnboarding({ reason })), + ), }) /** A full reload, so a cached `user.me` can't bounce the user back here. */ diff --git a/apps/web-foldkit/src/page/onboarding/message.ts b/apps/web-foldkit/src/page/onboarding/message.ts index f579d8a78..19f858f79 100644 --- a/apps/web-foldkit/src/page/onboarding/message.ts +++ b/apps/web-foldkit/src/page/onboarding/message.ts @@ -1,9 +1,9 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../ui/aria/interaction" import * as ChoiceBox from "../../ui/choice-box" -import { Membership, Theme } from "./model" +import { Membership, Theme, UpdateUserFailure } from "./model" export const Message = defineMessageUnion({ DetectedBrowserTimezone: { browserTimezone: Schema.String }, @@ -15,7 +15,7 @@ export const Message = defineMessageUnion({ ChangedLastName: { value: Schema.String }, SubmittedProfile: {}, SucceededUpdateProfile: {}, - FailedUpdateProfile: {}, + FailedUpdateProfile: { reason: UpdateUserFailure }, // Timezone ChangedTimezoneQuery: { value: Schema.String }, ElapsedTimezoneDebounce: { query: Schema.String }, @@ -25,7 +25,7 @@ export const Message = defineMessageUnion({ ClickedDetectTimezone: {}, ClickedContinueTimezone: {}, SucceededUpdateTimezone: { timezone: Schema.String }, - FailedUpdateTimezone: { toast: ToastRequest }, + FailedUpdateTimezone: { reason: UpdateUserFailure }, // Theme SelectedBrandColor: { hex: Schema.String }, SelectedTheme: { theme: Theme }, @@ -34,15 +34,17 @@ export const Message = defineMessageUnion({ GotChoiceBoxMessage: { message: ChoiceBox.Message }, ClickedContinueChoice: {}, // Invite - ChangedEmail: { index: Schema.Number, value: Schema.String }, + ChangedEmail: { rowId: Schema.Number, value: Schema.String }, ClickedAddEmail: {}, - ClickedRemoveEmail: { index: Schema.Number }, + ClickedRemoveEmail: { rowId: Schema.Number }, ClickedContinueInvite: {}, SucceededSendInvites: { emails: Schema.Array(Schema.String), failedCount: Schema.Number }, - FailedSendInvites: { reason: Schema.Literals(["NoOrganization", "AllFailed"]) }, + /** Every invitation failed (`sendInvitations` reports per-address failures as counts). */ + FailedSendInvites: { reason: Schema.Literals(["AllFailed"]) }, // Finalization SucceededCompleteOnboarding: {}, - FailedCompleteOnboarding: { error: Schema.String }, + FailedCompleteOnboarding: { reason: CommonReason }, + ClickedRetryFinalize: {}, CompletedReplaceStepUrl: {}, CompletedLoadHome: {}, CompletedAutoFocus: {}, diff --git a/apps/web-foldkit/src/page/onboarding/model.ts b/apps/web-foldkit/src/page/onboarding/model.ts index 8f5715c24..c36a6b315 100644 --- a/apps/web-foldkit/src/page/onboarding/model.ts +++ b/apps/web-foldkit/src/page/onboarding/model.ts @@ -1,10 +1,11 @@ import { OrganizationId, OrganizationMemberId } from "@hazel/schema" -import { Schema } from "effect" +import { Match, Schema } from "effect" import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import { ThemeMode } from "../../theme" import * as Interaction from "../../ui/aria/interaction" import * as ChoiceBox from "../../ui/choice-box" -import { Direction, Step, UserType } from "./flow" +import { Direction, type Step, UserType } from "./flow" /** The theme step reads and requests `Shared.theme`; it keeps no theme state of its own. */ export const Theme = ThemeMode @@ -24,13 +25,30 @@ export const Data = Schema.Struct({ timezone: Schema.NullOr(Schema.String), useCases: Schema.Array(Schema.String), role: Schema.NullOr(Schema.String), - emails: Schema.Array(Schema.String), }) export type Data = typeof Data.Type -/** Each step's local form state; it resets whenever the step is entered, like a remount. */ -export const StepForm = defineTaggedUnion({ - None: {}, +/** Why `user.update` failed (the profile and timezone steps), beyond the shared reasons. */ +export const UpdateUserFailure = Schema.Union([Schema.Literals(["UserNotFound"]), CommonReason]) +export type UpdateUserFailure = typeof UpdateUserFailure.Type + +export const InviteRowError = Schema.Literals(["InvalidEmail"]) +export type InviteRowError = typeof InviteRowError.Type + +/** One address field; `id` keys the row, so removing one keeps focus on the right input. */ +export const InviteRow = Schema.Struct({ + id: Schema.Number, + email: Schema.String, + error: Schema.Option(InviteRowError), +}) +export type InviteRow = typeof InviteRow.Type + +/** + * The step on screen with its own form state. A step's state is created fresh whenever the step + * is entered, like the legacy step components remounting. + */ +export const StepState = defineTaggedUnion({ + Welcome: {}, Profile: { firstName: Schema.String, lastName: Schema.String, @@ -47,34 +65,80 @@ export const StepForm = defineTaggedUnion({ isSubmitting: Schema.Boolean, }, Theme: {}, - Choice: { box: ChoiceBox.Model }, - Invite: { - emails: Schema.Array(Schema.String), - errors: Schema.Record(Schema.String, Schema.String), - isLoading: Schema.Boolean, + UseCases: { box: ChoiceBox.Model }, + Role: { box: ChoiceBox.Model }, + Invite: { rows: Schema.Array(InviteRow), nextRowId: Schema.Number, isSending: Schema.Boolean }, + /** `failure` once `user.finalizeOnboarding` failed; the user can retry from here. */ + Finalizing: { failure: Schema.Option(CommonReason) }, + Completed: {}, +}) +export type StepState = typeof StepState.Type +export type StepOf<Tag extends StepState["_tag"]> = Extract<StepState, { readonly _tag: Tag }> + +/** The flow, fixed when it starts (`userType`) or kept current by the live query (`membership`). */ +export const Flow = Schema.Struct({ + userType: UserType, + membership: Schema.NullOr(Membership), + browserTimezone: Schema.String, +}) +export type Flow = typeof Flow.Type + +/** + * `Starting`: waiting for the browser timezone and the membership query, both needed to pick the + * flow. `Leaving`: an onboarded user who landed here is on the way to `/`. + */ +export const Phase = defineTaggedUnion({ + Starting: { + membership: Schema.Option(Schema.NullOr(Membership)), + browserTimezone: Schema.Option(Schema.String), + }, + Leaving: {}, + Running: { + flow: Flow, + step: StepState, + direction: Direction, + /** `AnimatePresence initial={false}`: the first step appears without its enter transition. */ + animatesStep: Schema.Boolean, + data: Data, }, }) -export type StepForm = typeof StepForm.Type +export type Phase = typeof Phase.Type +export type Running = Extract<Phase, { readonly _tag: "Running" }> export const Model = Schema.Struct({ /** The route's `?step=` and `?orgId=` at load; later step changes rewrite the URL from these. */ urlStep: Schema.NullOr(Schema.String), orgId: Schema.NullOr(OrganizationId), - /** `undefined` until the live query is ready. */ - membership: Schema.UndefinedOr(Schema.NullOr(Membership)), - isInitialized: Schema.Boolean, - step: Step, - direction: Direction, - /** `AnimatePresence initial={false}`: the first step appears without its enter transition. */ - animatesStep: Schema.Boolean, - userType: UserType, - data: Data, - form: StepForm, - error: Schema.NullOr(Schema.String), - /** `undefined` until read; the flow starts once it is known. */ - browserTimezone: Schema.UndefinedOr(Schema.String), - hasRedirected: Schema.Boolean, + phase: Phase, /** Focus and hover state for the form fields (React Aria's `data-focused` and friends). */ interaction: Interaction.Model, }) export type Model = typeof Model.Type + +/** The flow position (and `?step=` value) of a step state. */ +export const stepOf = (state: StepState): Step => + Match.valueTags(state, { + Welcome: (): Step => "welcome", + Profile: (): Step => "profileInfo", + Timezone: (): Step => "timezoneSelection", + Theme: (): Step => "themeSelection", + UseCases: (): Step => "useCases", + Role: (): Step => "role", + Invite: (): Step => "teamInvitation", + Finalizing: (): Step => "finalization", + Completed: (): Step => "completed", + }) + +/** A step with a request in flight (or finishing the flow): Back and resubmits wait for it. */ +export const isBusy = (state: StepState): boolean => + Match.valueTags(state, { + Welcome: () => false, + Profile: ({ isSubmitting }) => isSubmitting, + Timezone: ({ isSubmitting }) => isSubmitting, + Theme: () => false, + UseCases: () => false, + Role: () => false, + Invite: ({ isSending }) => isSending, + Finalizing: () => true, + Completed: () => true, + }) diff --git a/apps/web-foldkit/src/page/onboarding/scene.test.ts b/apps/web-foldkit/src/page/onboarding/scene.test.ts index c591f6f8d..887109d34 100644 --- a/apps/web-foldkit/src/page/onboarding/scene.test.ts +++ b/apps/web-foldkit/src/page/onboarding/scene.test.ts @@ -4,9 +4,15 @@ import { Command, Mount, all, click, expect, expectAll, expectOutMessage, given, import { describe, test, vi, expect as vitestExpect } from "vitest" import type { Shared } from "../contract" import { PageOutMessage } from "../out-message" -import { CompleteOnboarding, ReplaceOnboardingStepUrl, SendInvites, UpdateProfile } from "./command" +import { + CompleteOnboarding, + LoadOnboardingHome, + ReplaceOnboardingStepUrl, + SendInvites, + UpdateProfile, +} from "./command" import { Message } from "./message" -import type { Model } from "./model" +import type { Membership, Model } from "./model" import { EnterAnimation } from "./enter-animation" import { AutoFocus } from "./navigation" import { DrawGlobePath, TwinkleStar } from "./timezone/globe" @@ -47,6 +53,9 @@ const membership = { slug: "hazel", } +/** A creator's membership (no slug yet), so the invite step has an organization to invite into. */ +const creator = { ...membership, slug: null } + const config = { update: (model: Model, message: Message) => update(model, message, shared), view: (model: Model, h: Parameters<typeof view>[2]) => view(model, { shared }, h), @@ -62,7 +71,7 @@ const darkConfig = { /** The page once `?step=` and the membership are known. */ const ready = ( urlStep: string | null, - member: typeof membership | null = null, + member: Membership | null = null, extra: ReadonlyArray<Message> = [], ): Model => { const steps = [ @@ -160,18 +169,24 @@ describe("onboarding flow", () => { expect(text("Setting up your workspace...")).toExist(), Mount.expectEnded(AutoFocus), Mount.resolve(EnterAnimation, settled), - Command.resolve( - CompleteOnboarding, - Message.FailedCompleteOnboarding({ error: "Failed to finalize onboarding" }), + Command.resolve(CompleteOnboarding, Message.FailedCompleteOnboarding({ reason: "Offline" })), + expect(text("Setting up your workspace...")).toBeAbsent(), + expect(role("alert")).toHaveText("Connection lost. Check your internet connection and try again."), + click(role("button", { name: "Try again" })), + Command.expectExact(CompleteOnboarding), + expect(text("Setting up your workspace...")).toExist(), + Command.resolve(CompleteOnboarding, Message.SucceededCompleteOnboarding()), + Command.resolveAll( + [ReplaceOnboardingStepUrl, Message.CompletedReplaceStepUrl()], + [LoadOnboardingHome, Message.CompletedLoadHome()], ), - expect(text("Failed to finalize onboarding")).toExist(), ) }) test("an invalid invite address blocks sending until it is fixed", () => { scene( config, - given(ready("teamInvitation")), + given(ready("teamInvitation", creator)), Mount.resolveAll([EnterAnimation, settled], [AutoFocus, focused]), type(role("textbox", { name: "Email 1" }), "grace@"), expect(role("button", { name: /Skip for now/ })).toExist(), @@ -183,7 +198,8 @@ describe("onboarding flow", () => { expect(text("Please enter a valid email address")).not.toExist(), click(role("button", { name: /Send invites/ })), Command.expectExact(SendInvites), - Command.resolve(SendInvites, Message.FailedSendInvites({ reason: "NoOrganization" })), + Command.resolve(SendInvites, Message.FailedSendInvites({ reason: "AllFailed" })), + expectOutMessage(errorOut("Failed to send invitations")), expect(role("button", { name: /Send invites/ })).toBeEnabled(), ) }) @@ -203,7 +219,8 @@ describe("onboarding flow", () => { }) const firstMounts = Mount.resolveAll([EnterAnimation, settled], [AutoFocus, focused]) -const errorOut = (title: string) => PageOutMessage.RequestedToast({ toast: errorToast(title) }) +const errorOut = (title: string, description: string | null = null) => + PageOutMessage.RequestedToast({ toast: errorToast(title, description) }) describe("profile step validation", () => { test("clearing a name marks only that input invalid, and Enter does not submit", () => { @@ -229,8 +246,8 @@ describe("profile step validation", () => { click(role("button", { name: /Continue/ })), expect(role("button", { name: /Continue/ })).toBeDisabled(), expect(role("button", { name: /Back/ })).toBeDisabled(), - Command.resolve(UpdateProfile, Message.FailedUpdateProfile()), - expectOutMessage(errorOut("Failed to update profile")), + Command.resolve(UpdateProfile, Message.FailedUpdateProfile({ reason: "Unexpected" })), + expectOutMessage(errorOut("Something went wrong", "Please try again.")), expect(role("heading", { name: "Set up your profile" })).toExist(), expect(role("button", { name: /Continue/ })).toBeEnabled(), ) @@ -243,7 +260,7 @@ describe("invite step", () => { test("rows can be added and removed, and only extra rows have a remove button", () => { scene( config, - given(ready("teamInvitation")), + given(ready("teamInvitation", creator)), firstMounts, expectAll(removeButtons).toHaveCount(0), click(role("button", { name: /Add another email/ })), @@ -259,7 +276,7 @@ describe("invite step", () => { const addRow = click(role("button", { name: /Add another email/ })) scene( config, - given(ready("teamInvitation")), + given(ready("teamInvitation", creator)), firstMounts, addRow, addRow, addRow, addRow, addRow, addRow, addRow, addRow, addRow, expect(role("textbox", { name: "Email 10" })).toExist(), @@ -271,11 +288,11 @@ describe("invite step", () => { test("sending locks the step, then a full success toasts and finalizes", () => { scene( config, - given(ready("teamInvitation")), + given(ready("teamInvitation", creator)), firstMounts, type(role("textbox", { name: "Email 1" }), "grace@hazel.test"), click(role("button", { name: /Send invites/ })), - Command.expectExact(SendInvites({ organizationId: null, emails: ["grace@hazel.test"] })), + Command.expectExact(SendInvites({ organizationId: creator.organizationId, emails: ["grace@hazel.test"] })), expect(role("button", { name: /Send invites/ })).toBeDisabled(), expect(role("button", { name: /Back/ })).toBeDisabled(), Command.resolve(SendInvites, Message.SucceededSendInvites({ emails: ["grace@hazel.test"], failedCount: 0 })), @@ -286,14 +303,14 @@ describe("invite step", () => { expect(text("Setting up your workspace...")).toExist(), Mount.expectEnded(AutoFocus), Mount.resolve(EnterAnimation, settled), - Command.resolve(CompleteOnboarding, Message.FailedCompleteOnboarding({ error: "Failed to finalize onboarding" })), + Command.resolve(CompleteOnboarding, Message.FailedCompleteOnboarding({ reason: "Offline" })), ) }) test("when every invitation fails the address stays for another try", () => { scene( config, - given(ready("teamInvitation")), + given(ready("teamInvitation", creator)), firstMounts, type(role("textbox", { name: "Email 1" }), "grace@hazel.test"), click(role("button", { name: /Send invites/ })), diff --git a/apps/web-foldkit/src/page/onboarding/steps/invite.ts b/apps/web-foldkit/src/page/onboarding/steps/invite.ts index 469bd1073..a765326d0 100644 --- a/apps/web-foldkit/src/page/onboarding/steps/invite.ts +++ b/apps/web-foldkit/src/page/onboarding/steps/invite.ts @@ -1,3 +1,4 @@ +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { IconClose, IconPlus } from "../../../icons" import type * as Interaction from "../../../ui/aria/interaction" @@ -5,70 +6,68 @@ import { button } from "../../../ui/button" import { description } from "../../../ui/field" import { textField } from "../../../ui/text-field" import { Message } from "../message" -import type { StepForm } from "../model" +import type { InviteRow, StepOf } from "../model" import { autoFocus, onboardingNavigation, stepHeader } from "../navigation" +import { hasSendableEmails, MAX_INVITES } from "../validation" /** `invite-team-step.tsx` */ -type InviteForm = Extract<StepForm, { _tag: "Invite" }> - -const MAX_INVITES = 10 -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +type InviteForm = StepOf<"Invite"> +/** Keyed by the row's id, so removing a row keeps every other input (and its focus) in place. */ const emailField = ( h: HtmlBuilder<Message>, form: InviteForm, interaction: Interaction.Wiring<Message>, - email: string, + row: InviteRow, index: number, -): Html => { - const error = form.errors[String(index)] - return textField( - h, - { - id: `onboarding-invite-${index}`, - value: email, - isInvalid: error !== undefined, - onInput: (value) => Message.ChangedEmail({ index, value }), - interaction, - }, - (parts) => [ - parts.label(["Email ", String(index + 1)], { className: "sr-only" }), - h.div( - [h.Class("flex items-center gap-2")], - [ - parts.input({ - placeholder: "colleague@example.com", - attributes: [h.Type("email"), ...(index === 0 ? [autoFocus(h)] : [])], - }), - ...(form.emails.length > 1 - ? [ - button( - h, - { - intent: "secondary", - size: "sq-sm", - onPress: Message.ClickedRemoveEmail({ index }), - attributes: [h.AriaLabel("Remove email")], - }, - [IconClose(h, { className: "size-4" })], - ), - ] - : []), - ], - ), - ...(error === undefined ? [] : [parts.fieldError([error])]), - ], - ) -} +): Html => + h.keyed("div")(`invite-row-${row.id}`, [], [ + textField( + h, + { + id: `onboarding-invite-${row.id}`, + value: row.email, + isInvalid: Option.isSome(row.error), + onInput: (value) => Message.ChangedEmail({ rowId: row.id, value }), + interaction, + }, + (parts) => [ + parts.label(["Email ", String(index + 1)], { className: "sr-only" }), + h.div( + [h.Class("flex items-center gap-2")], + [ + parts.input({ + placeholder: "colleague@example.com", + attributes: [h.Type("email"), ...(index === 0 ? [autoFocus(h)] : [])], + }), + ...(form.rows.length > 1 + ? [ + button( + h, + { + intent: "secondary", + size: "sq-sm", + onPress: Message.ClickedRemoveEmail({ rowId: row.id }), + attributes: [h.AriaLabel("Remove email")], + }, + [IconClose(h, { className: "size-4" })], + ), + ] + : []), + ], + ), + ...(Option.isSome(row.error) ? [parts.fieldError(["Please enter a valid email address"])] : []), + ], + ), + ]) export const inviteStep = ( h: HtmlBuilder<Message>, form: InviteForm, interaction: Interaction.Wiring<Message>, ): Html => { - const hasValidEmails = form.emails.some((email) => email.trim().length > 0 && EMAIL_PATTERN.test(email)) - const isFull = form.emails.length >= MAX_INVITES + const isFull = form.rows.length >= MAX_INVITES return h.div( [h.Class("space-y-4 sm:space-y-6"), h.DataAttribute("testid", "onboarding-step-invite")], [ @@ -80,7 +79,7 @@ export const inviteStep = ( h.div( [h.Class("space-y-3")], [ - ...form.emails.map((email, index) => emailField(h, form, interaction, email, index)), + ...form.rows.map((row, index) => emailField(h, form, interaction, row, index)), button( h, { @@ -94,7 +93,7 @@ export const inviteStep = ( ...(isFull ? [ description(h, {}, [ - "Maximum of 10 invites at a time. You can invite more later.", + `Maximum of ${MAX_INVITES} invites at a time. You can invite more later.`, ]), ] : []), @@ -102,8 +101,8 @@ export const inviteStep = ( ), onboardingNavigation(h, { onContinue: Message.ClickedContinueInvite(), - isLoading: form.isLoading, - continueLabel: hasValidEmails ? "Send invites" : "Skip for now", + isLoading: form.isSending, + continueLabel: hasSendableEmails(form.rows) ? "Send invites" : "Skip for now", }), ], ) diff --git a/apps/web-foldkit/src/page/onboarding/steps/profile.ts b/apps/web-foldkit/src/page/onboarding/steps/profile.ts index 66befc96a..f251423b8 100644 --- a/apps/web-foldkit/src/page/onboarding/steps/profile.ts +++ b/apps/web-foldkit/src/page/onboarding/steps/profile.ts @@ -2,12 +2,13 @@ import type { Html, HtmlBuilder } from "foldkit/html" import type * as Interaction from "../../../ui/aria/interaction" import { textField } from "../../../ui/text-field" import { Message } from "../message" -import type { StepForm } from "../model" +import type { StepOf } from "../model" import { autoFocus, onboardingNavigation, stepHeader } from "../navigation" +import { profileErrors } from "../validation" /** `profile-info-step.tsx`: the form validates on change, so errors appear after the first edit. */ -type ProfileForm = Extract<StepForm, { _tag: "Profile" }> +type ProfileForm = StepOf<"Profile"> const nameField = ( h: HtmlBuilder<Message>, @@ -57,8 +58,9 @@ export const profileStep = ( form: ProfileForm, interaction: Interaction.Wiring<Message>, ): Html => { - const isFirstNameInvalid = form.hasChanged && form.firstName.length === 0 - const isLastNameInvalid = form.hasChanged && form.lastName.length === 0 + const errors = profileErrors(form) + const isFirstNameInvalid = form.hasChanged && errors.firstName + const isLastNameInvalid = form.hasChanged && errors.lastName return h.div( [h.Class("space-y-4 sm:space-y-6"), h.DataAttribute("testid", "onboarding-step-profile")], [ diff --git a/apps/web-foldkit/src/page/onboarding/story.test.ts b/apps/web-foldkit/src/page/onboarding/story.test.ts index c2eceb8d3..e9c12c0e8 100644 --- a/apps/web-foldkit/src/page/onboarding/story.test.ts +++ b/apps/web-foldkit/src/page/onboarding/story.test.ts @@ -1,16 +1,19 @@ // @vitest-environment jsdom +import { Option } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { makeShared, storyUpdate, userId } from "../../test/pages-fixtures" +import { errorToast } from "../../data/actions" +import { onboardingHref } from "../../route" import { creatorMembership, invitedMembership, newcomer, onboardingAt, onboardingRoute, - withForm, + runningOf, + withStep, } from "../../test/pages-entry-fixtures" -import { onboardingHref } from "../../route" +import { makeShared, storyUpdate, userId } from "../../test/pages-fixtures" import * as ChoiceBox from "../../ui/choice-box" import { PageOutMessage } from "../out-message" import { @@ -24,9 +27,8 @@ import { UpdateUserTimezone, } from "./command" import { Message } from "./message" -import { StepForm } from "./model" +import { type InviteRow, type Model, StepState, stepOf } from "./model" import { init, sharedChanged, update } from "./update" -import { errorToast } from "../../data/actions" /** The onboarding update loop: start-up, each step's form, and finalization. */ @@ -36,11 +38,15 @@ const urlDone = Message.CompletedReplaceStepUrl() const errorOut = (title: string, description: string | null = null) => PageOutMessage.RequestedToast({ toast: errorToast(title, description) }) +const stepNameOf = (current: Model) => stepOf(runningOf(current).step) +const stepStateOf = (current: Model) => runningOf(current).step + describe("start-up", () => { test("an onboarded user is sent home once, and later shared changes do not redirect again", () => { const onboarded = makeShared() const started = init(onboardingRoute(null), onboarded) expect(started.outMessage).toEqual(PageOutMessage.RequestedNavigation({ href: "/", replace: false })) + expect(started.model.phase._tag).toBe("Leaving") expect(started.commands?.map((command) => command.name)).toEqual([ReadBrowserTimezone.name]) expect(sharedChanged(started.model, onboarded).outMessage).toBeUndefined() }) @@ -50,61 +56,74 @@ describe("start-up", () => { run, given(init(onboardingRoute(null), newcomer).model), message(Message.DetectedBrowserTimezone({ browserTimezone: "Europe/Vienna" })), - model((current) => expect(current.isInitialized).toBe(false)), + model((current) => expect(current.phase._tag).toBe("Starting")), message(Message.UpdatedMembership({ membership: creatorMembership })), Command.expectNone(), model((current) => { - expect(current.isInitialized).toBe(true) - expect(current.userType).toBe("creator") - expect(current.step).toBe("welcome") - expect(current.animatesStep).toBe(false) + const running = runningOf(current) + expect(running.flow).toEqual({ + userType: "creator", + membership: creatorMembership, + browserTimezone: "Europe/Vienna", + }) + expect(running.step._tag).toBe("Welcome") + expect(running.animatesStep).toBe(false) }), ) }) test("a membership with a slug runs the invited flow, which ignores a creator-only ?step=", () => { const invited = onboardingAt("useCases", invitedMembership) - expect(invited.userType).toBe("invited") - expect(invited.step).toBe("welcome") - expect(onboardingAt("useCases", creatorMembership).step).toBe("useCases") + expect(runningOf(invited).flow.userType).toBe("invited") + expect(stepNameOf(invited)).toBe("welcome") + expect(stepNameOf(onboardingAt("useCases", creatorMembership))).toBe("useCases") }) - test("a later membership update does not restart the flow", () => { + test("a later membership update keeps the flow and its user type, but the membership is current", () => { story( run, given(onboardingAt("themeSelection")), message(Message.UpdatedMembership({ membership: invitedMembership })), Command.expectNone(), model((current) => { - expect(current.step).toBe("themeSelection") - expect(current.userType).toBe("creator") + expect(stepNameOf(current)).toBe("themeSelection") + expect(runningOf(current).flow.userType).toBe("creator") + expect(runningOf(current).flow.membership).toEqual(invitedMembership) }), ) }) test("Back on the first step stays put", () => { - story(run, given(onboardingAt(null)), message(Message.ClickedBack()), Command.expectNone(), model((current) => expect(current.step).toBe("welcome"))) + story( + run, + given(onboardingAt(null)), + message(Message.ClickedBack()), + Command.expectNone(), + model((current) => expect(stepNameOf(current)).toBe("welcome")), + ) }) }) describe("profile step", () => { const profile = onboardingAt("profileInfo") + const profileForm = (firstName: string, lastName: string, isSubmitting = false) => + withStep(profile, StepState.Profile({ firstName, lastName, hasChanged: false, isSubmitting })) test("the form is seeded from user.me and submits the trimmed names", () => { story( run, given(profile), - model((current) => expect(current.form).toMatchObject({ firstName: "Nora", lastName: "Newcomer" })), + model((current) => expect(stepStateOf(current)).toMatchObject({ firstName: "Nora", lastName: "Newcomer" })), message(Message.ChangedFirstName({ value: " Ada " })), message(Message.SubmittedProfile()), Command.expectExact(UpdateProfile({ userId, firstName: "Ada", lastName: "Newcomer" })), - model((current) => expect(current.form).toMatchObject({ isSubmitting: true })), + model((current) => expect(stepStateOf(current)).toMatchObject({ isSubmitting: true })), Command.resolve(UpdateProfile, Message.SucceededUpdateProfile()), Command.expectExact(urlFor("timezoneSelection")), Command.resolve(ReplaceOnboardingStepUrl, urlDone), model((current) => { - expect(current.step).toBe("timezoneSelection") - expect(current.direction).toBe("forward") + expect(stepNameOf(current)).toBe("timezoneSelection") + expect(runningOf(current).direction).toBe("forward") }), ) }) @@ -112,42 +131,43 @@ describe("profile step", () => { test("an empty name is not submitted and marks the form as edited", () => { story( run, - given(withForm(profile, StepForm.Profile({ firstName: "", lastName: "Newcomer", hasChanged: false, isSubmitting: false }))), + given(profileForm("", "Newcomer")), message(Message.SubmittedProfile()), Command.expectNone(), - model((current) => expect(current.form).toMatchObject({ hasChanged: true, isSubmitting: false })), + model((current) => expect(stepStateOf(current)).toMatchObject({ hasChanged: true, isSubmitting: false })), ) }) - test("a failed Clerk update re-enables the form and toasts", () => { + test("a failed profile update re-enables the form and says why", () => { story( run, given(profile), message(Message.SubmittedProfile()), - Command.resolve(UpdateProfile, Message.FailedUpdateProfile()), - expectOutMessage(errorOut("Failed to update profile")), + Command.resolve(UpdateProfile, Message.FailedUpdateProfile({ reason: "Offline" })), + expectOutMessage(errorOut("Connection lost", "Check your internet connection and try again.")), model((current) => { - expect(current.step).toBe("profileInfo") - expect(current.form).toMatchObject({ isSubmitting: false }) + expect(stepNameOf(current)).toBe("profileInfo") + expect(stepStateOf(current)).toMatchObject({ isSubmitting: false }) }), ) }) // SubmittedProfile is guarded on isSubmitting, so Enter while saving sends no second update. test("a submit while saving sends nothing", () => { + story(run, given(profileForm("Nora", "Newcomer", true)), message(Message.SubmittedProfile()), Command.expectNone()) + }) + + // Validation trims, so " " is not sent as a first name. + test("a whitespace-only name is not submitted", () => { story( run, - given(withForm(profile, StepForm.Profile({ firstName: "Nora", lastName: "Newcomer", hasChanged: false, isSubmitting: true }))), + given(profile), + message(Message.ChangedFirstName({ value: " " })), message(Message.SubmittedProfile()), Command.expectNone(), ) }) - // Validation trims, so " " is not sent to Clerk as a first name. - test("a whitespace-only name is not submitted", () => { - story(run, given(profile), message(Message.ChangedFirstName({ value: " " })), message(Message.SubmittedProfile()), Command.expectNone()) - }) - test("a second profile success after the step moved on does not skip the timezone step", () => { story( run, @@ -157,20 +177,54 @@ describe("profile step", () => { Command.resolve(ReplaceOnboardingStepUrl, urlDone), message(Message.SucceededUpdateProfile()), Command.expectNone(), - model((current) => expect(current.step).toBe("timezoneSelection")), + model((current) => expect(stepNameOf(current)).toBe("timezoneSelection")), + ) + }) + + // Back then forward made a fresh form, and the earlier save's late success advanced it. + test("Back waits while the profile is saving", () => { + const saving = run(profile, Message.SubmittedProfile()).model + const back = run(saving, Message.ClickedBack()) + expect(back.commands ?? []).toEqual([]) + expect(stepStateOf(back.model)).toMatchObject({ _tag: "Profile", isSubmitting: true }) + expect(stepNameOf(run(back.model, Message.SucceededUpdateProfile()).model)).toBe("timezoneSelection") + }) + + test("a profile success the form did not ask for is ignored", () => { + story( + run, + given(profile), + message(Message.SucceededUpdateProfile()), + Command.expectNone(), + model((current) => expect(stepNameOf(current)).toBe("profileInfo")), ) }) test("user.me arriving late seeds an untouched empty form", () => { - const empty = withForm(profile, StepForm.Profile({ firstName: "", lastName: "", hasChanged: false, isSubmitting: false })) - expect(sharedChanged(empty, newcomer).model.form).toMatchObject({ firstName: "Nora", lastName: "Newcomer" }) - const edited = withForm(profile, StepForm.Profile({ firstName: "", lastName: "", hasChanged: true, isSubmitting: false })) - expect(sharedChanged(edited, newcomer).model.form).toMatchObject({ firstName: "", lastName: "" }) + const empty = profileForm("", "") + expect(stepStateOf(sharedChanged(empty, newcomer).model)).toMatchObject({ + firstName: "Nora", + lastName: "Newcomer", + }) + const edited = withStep(profile, StepState.Profile({ firstName: "", lastName: "", hasChanged: true, isSubmitting: false })) + expect(stepStateOf(sharedChanged(edited, newcomer).model)).toMatchObject({ firstName: "", lastName: "" }) }) }) describe("timezone step", () => { const timezone = onboardingAt("timezoneSelection") + const timezoneForm = (selected: string, isSubmitting = false) => + withStep( + timezone, + StepState.Timezone({ + selected, + query: "", + debouncedQuery: "", + hoveredOffset: null, + detectionAttempted: false, + isSubmitting, + }), + ) test("search is debounced, and a stale debounce is dropped", () => { story( @@ -178,21 +232,23 @@ describe("timezone step", () => { given(timezone), message(Message.ChangedTimezoneQuery({ value: "vie" })), Command.resolve(DebounceTimezoneQuery({ query: "vie" }), Message.ElapsedTimezoneDebounce({ query: "vi" })), - model((current) => expect(current.form).toMatchObject({ query: "vie", debouncedQuery: "" })), + model((current) => expect(stepStateOf(current)).toMatchObject({ query: "vie", debouncedQuery: "" })), message(Message.ChangedTimezoneQuery({ value: "vienna" })), Command.resolve(DebounceTimezoneQuery, Message.ElapsedTimezoneDebounce({ query: "vienna" })), - model((current) => expect(current.form).toMatchObject({ debouncedQuery: "vienna" })), + model((current) => expect(stepStateOf(current)).toMatchObject({ debouncedQuery: "vienna" })), ) }) test("an offset without a city keeps the selection; Detect picks the browser timezone", () => { story( run, - given(withForm(timezone, StepForm.Timezone({ selected: "Asia/Tokyo", query: "", debouncedQuery: "", hoveredOffset: null, detectionAttempted: false, isSubmitting: false }))), + given(timezoneForm("Asia/Tokyo")), message(Message.ClickedOffset({ offset: 99 })), - model((current) => expect(current.form).toMatchObject({ selected: "Asia/Tokyo" })), + model((current) => expect(stepStateOf(current)).toMatchObject({ selected: "Asia/Tokyo" })), message(Message.ClickedDetectTimezone()), - model((current) => expect(current.form).toMatchObject({ selected: "UTC", detectionAttempted: true })), + model((current) => + expect(stepStateOf(current)).toMatchObject({ selected: "UTC", detectionAttempted: true }), + ), ) }) @@ -206,31 +262,28 @@ describe("timezone step", () => { Command.resolve(UpdateUserTimezone, Message.SucceededUpdateTimezone({ timezone: "Europe/Vienna" })), Command.resolve(urlFor("themeSelection"), urlDone), model((current) => { - expect(current.step).toBe("themeSelection") - expect(current.data.timezone).toBe("Europe/Vienna") + expect(stepNameOf(current)).toBe("themeSelection") + expect(runningOf(current).data.timezone).toBe("Europe/Vienna") }), ) }) - test("a failed save re-enables Continue and shows the server's reason", () => { + test("a failed save re-enables Continue and says why", () => { story( run, given(timezone), message(Message.ClickedContinueTimezone()), - Command.resolve(UpdateUserTimezone, Message.FailedUpdateTimezone({ toast: errorToast("User not found", "Sign in again.") })), - expectOutMessage(errorOut("User not found", "Sign in again.")), - model((current) => expect(current.form).toMatchObject({ isSubmitting: false })), + Command.resolve(UpdateUserTimezone, Message.FailedUpdateTimezone({ reason: "UserNotFound" })), + expectOutMessage( + errorOut("User not found", "Your account could not be found. Please try signing in again."), + ), + model((current) => expect(stepStateOf(current)).toMatchObject({ isSubmitting: false })), ) }) // ClickedContinueTimezone is guarded on isSubmitting, so a second press does not save twice. test("Continue while saving sends nothing", () => { - story( - run, - given(withForm(timezone, StepForm.Timezone({ selected: "UTC", query: "", debouncedQuery: "", hoveredOffset: null, detectionAttempted: false, isSubmitting: true }))), - message(Message.ClickedContinueTimezone()), - Command.expectNone(), - ) + story(run, given(timezoneForm("UTC", true)), message(Message.ClickedContinueTimezone()), Command.expectNone()) }) }) @@ -241,149 +294,234 @@ describe("theme and choice steps", () => { given(onboardingAt("themeSelection")), message(Message.SucceededUpdateTimezone({ timezone: "UTC" })), Command.expectNone(), - model((current) => expect(current.step).toBe("themeSelection")), + model((current) => expect(stepNameOf(current)).toBe("themeSelection")), ) story( run, given(onboardingAt("useCases")), message(Message.ClickedContinueTheme()), Command.expectNone(), - model((current) => expect(current.step).toBe("useCases")), + model((current) => expect(stepNameOf(current)).toBe("useCases")), ) }) test("an invalid brand color is ignored", () => { - story(run, given(onboardingAt("themeSelection")), message(Message.SelectedBrandColor({ hex: "green" })), expectNoOutMessage()) + story( + run, + given(onboardingAt("themeSelection")), + message(Message.SelectedBrandColor({ hex: "green" })), + expectNoOutMessage(), + ) }) test("Continue without a team size does nothing; with one it records it", () => { const useCases = onboardingAt("useCases") - story(run, given(useCases), message(Message.ClickedContinueChoice()), Command.expectNone(), model((current) => expect(current.step).toBe("useCases"))) story( run, - given(withForm(useCases, StepForm.Choice({ box: ChoiceBox.init({ id: "team-size", selectedKeys: ["small"] }) }))), + given(useCases), + message(Message.ClickedContinueChoice()), + Command.expectNone(), + model((current) => expect(stepNameOf(current)).toBe("useCases")), + ) + story( + run, + given(withStep(useCases, StepState.UseCases({ box: ChoiceBox.init({ id: "team-size", selectedKeys: ["small"] }) }))), message(Message.ClickedContinueChoice()), Command.resolve(urlFor("role"), urlDone), model((current) => { - expect(current.data.useCases).toEqual(["small"]) - expect(current.form).toMatchObject({ box: { selectedKeys: [] } }) + expect(runningOf(current).data.useCases).toEqual(["small"]) + expect(stepStateOf(current)).toMatchObject({ _tag: "Role", box: { selectedKeys: [] } }) }), ) }) }) describe("invite step and finalization", () => { - const invite = onboardingAt("teamInvitation") - const inviteForm = (emails: ReadonlyArray<string>, isLoading = false) => - withForm(invite, StepForm.Invite({ emails: [...emails], errors: {}, isLoading })) + const invite = onboardingAt("teamInvitation", creatorMembership) + const rowsOf = (emails: ReadonlyArray<string>): ReadonlyArray<InviteRow> => + emails.map((email, id) => ({ id, email, error: Option.none() })) + const inviteForm = (emails: ReadonlyArray<string>, isSending = false) => + withStep(invite, StepState.Invite({ rows: rowsOf(emails), nextRowId: emails.length, isSending })) + const inviteRows = (current: Model) => { + const step = stepStateOf(current) + return step._tag === "Invite" ? step.rows : [] + } + const completeFor = CompleteOnboarding({ memberId: creatorMembership.memberId, role: null, useCases: [] }) test("adding, editing and removing rows keeps at least one field", () => { story( run, given(invite), message(Message.ClickedAddEmail()), - message(Message.ChangedEmail({ index: 1, value: "grace@hazel.test" })), - model((current) => expect(current.form).toMatchObject({ emails: ["", "grace@hazel.test"] })), - message(Message.ClickedRemoveEmail({ index: 0 })), - message(Message.ClickedRemoveEmail({ index: 0 })), - model((current) => expect(current.form).toMatchObject({ emails: [""] })), + message(Message.ChangedEmail({ rowId: 1, value: "grace@hazel.test" })), + model((current) => expect(inviteRows(current).map((row) => row.email)).toEqual(["", "grace@hazel.test"])), + message(Message.ClickedRemoveEmail({ rowId: 0 })), + message(Message.ClickedRemoveEmail({ rowId: 1 })), + model((current) => { + expect(inviteRows(current).map((row) => row.email)).toEqual([""]) + expect(inviteRows(current)[0]?.id).toBe(2) + }), + ) + }) + + test("Add stops at the limit, whatever the button shows", () => { + const full = inviteForm(Array.from({ length: 10 }, () => "")) + story( + run, + given(full), + message(Message.ClickedAddEmail()), + model((current) => expect(inviteRows(current)).toHaveLength(10)), ) }) - test("valid addresses are sent; full success toasts and finalizes with them", () => { + test("valid addresses are sent; full success toasts and finalizes", () => { const emails = ["grace@hazel.test", "alan@hazel.test"] story( run, given(inviteForm([...emails, " "])), message(Message.ClickedContinueInvite()), - Command.expectExact(SendInvites({ organizationId: null, emails })), - model((current) => expect(current.form).toMatchObject({ isLoading: true })), + Command.expectExact(SendInvites({ organizationId: creatorMembership.organizationId, emails })), + model((current) => expect(stepStateOf(current)).toMatchObject({ isSending: true })), Command.resolve(SendInvites, Message.SucceededSendInvites({ emails, failedCount: 0 })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: { intent: "success", title: "Sent 2 invitations", description: null } })), - Command.expectExact(urlFor("finalization"), CompleteOnboarding), - Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [CompleteOnboarding, Message.SucceededCompleteOnboarding()]), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { intent: "success", title: "Sent 2 invitations", description: null }, + }), + ), + Command.expectExact(urlFor("finalization"), completeFor), + Command.resolveAll( + [ReplaceOnboardingStepUrl, urlDone], + [CompleteOnboarding, Message.SucceededCompleteOnboarding()], + ), Command.expectExact(urlFor("completed"), LoadOnboardingHome({ href: "/" })), Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [LoadOnboardingHome, Message.CompletedLoadHome()]), - model((current) => { - expect(current.step).toBe("completed") - }), + model((current) => expect(stepNameOf(current)).toBe("completed")), ) }) test("a partial failure warns with the counts", () => { + const emails = ["a@hazel.test", "b@hazel.test", "c@hazel.test"] + story( + run, + given(inviteForm(emails, true)), + message(Message.SucceededSendInvites({ emails, failedCount: 2 })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { intent: "warning", title: "Sent 1 invitation, 2 failed", description: null }, + }), + ), + Command.resolveAll( + [ReplaceOnboardingStepUrl, urlDone], + [CompleteOnboarding, Message.SucceededCompleteOnboarding()], + ), + Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [LoadOnboardingHome, Message.CompletedLoadHome()]), + ) + }) + + test("a failed finalize stops the spinner and Try again finalizes again", () => { story( run, - given(inviteForm(["a@hazel.test", "b@hazel.test", "c@hazel.test"], true)), - message(Message.SucceededSendInvites({ emails: ["a@hazel.test", "b@hazel.test", "c@hazel.test"], failedCount: 2 })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: { intent: "warning", title: "Sent 1 invitation, 2 failed", description: null } })), - Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [CompleteOnboarding, Message.FailedCompleteOnboarding({ error: "Failed to finalize onboarding" })]), + given(inviteForm([])), + message(Message.ClickedContinueInvite()), + Command.resolveAll( + [ReplaceOnboardingStepUrl, urlDone], + [CompleteOnboarding, Message.FailedCompleteOnboarding({ reason: "Unavailable" })], + ), + model((current) => + expect(stepStateOf(current)).toEqual(StepState.Finalizing({ failure: Option.some("Unavailable") })), + ), + message(Message.ClickedBack()), + Command.expectNone(), + message(Message.ClickedRetryFinalize()), + Command.expectExact(completeFor), model((current) => { - expect(current.step).toBe("finalization") - expect(current.error).toBe("Failed to finalize onboarding") + expect(stepStateOf(current)).toEqual(StepState.Finalizing({ failure: Option.none() })) + // A second press while it runs sends nothing. + expect(run(current, Message.ClickedRetryFinalize()).commands ?? []).toEqual([]) }), + Command.resolve(CompleteOnboarding, Message.SucceededCompleteOnboarding()), + Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [LoadOnboardingHome, Message.CompletedLoadHome()]), + model((current) => expect(stepNameOf(current)).toBe("completed")), ) }) - test("without an active organization the send fails and the form unlocks", () => { + test("without an active organization nothing is sent and the form stays open", () => { story( run, - given(inviteForm(["grace@hazel.test"], true)), - message(Message.FailedSendInvites({ reason: "NoOrganization" })), + given(withStep(onboardingAt("teamInvitation"), StepState.Invite({ rows: rowsOf(["grace@hazel.test"]), nextRowId: 1, isSending: false }))), + message(Message.ClickedContinueInvite()), + Command.expectNone(), expectOutMessage(errorOut("No active organization")), - model((current) => expect(current.form).toMatchObject({ isLoading: false })), + model((current) => expect(stepStateOf(current)).toMatchObject({ isSending: false })), ) }) - // Continue is guarded on isLoading, so a second press while sending does not invite twice. - test("Continue while sending sends nothing", () => { - story(run, given(inviteForm(["grace@hazel.test"], true)), message(Message.ClickedContinueInvite()), Command.expectNone()) - }) - - // Legacy re-invited these addresses at finalization; the invite step is now the only sender. - test("finalization does not re-send invitations the step already sent", () => { + test("when every invitation fails the form unlocks", () => { story( run, given(inviteForm(["grace@hazel.test"], true)), - message(Message.SucceededSendInvites({ emails: ["grace@hazel.test"], failedCount: 0 })), - Command.expectHas(CompleteOnboarding({ memberId: null, role: null, useCases: [] })), - Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [CompleteOnboarding, Message.FailedCompleteOnboarding({ error: "x" })]), + message(Message.FailedSendInvites({ reason: "AllFailed" })), + expectOutMessage(errorOut("Failed to send invitations")), + model((current) => expect(stepStateOf(current)).toMatchObject({ isSending: false })), ) }) - // Errors are keyed by row index, which is how the view reads them. + // Continue is guarded on isSending, so a second press while sending does not invite twice. + test("Continue while sending sends nothing", () => { + story(run, given(inviteForm(["grace@hazel.test"], true)), message(Message.ClickedContinueInvite()), Command.expectNone()) + }) + test("an invalid address is flagged on its own row", () => { story( run, given(inviteForm(["", "grace@"])), message(Message.ClickedContinueInvite()), - model((current) => expect("errors" in current.form && current.form.errors).toEqual({ "1": "Please enter a valid email address" })), + Command.expectNone(), + model((current) => + expect(inviteRows(current).map((row) => row.error)).toEqual([Option.none(), Option.some("InvalidEmail")]), + ), ) }) - test("removing a row moves the errors of later rows up with them", () => { + test("removing a row keeps every other row's error with its row", () => { story( run, given(inviteForm(["bad@", "ok@hazel.test", "worse@"])), message(Message.ClickedContinueInvite()), - model((current) => expect(current.form).toMatchObject({ errors: { "0": "Please enter a valid email address", "2": "Please enter a valid email address" } })), - message(Message.ClickedRemoveEmail({ index: 0 })), - model((current) => { - expect(current.form).toMatchObject({ emails: ["ok@hazel.test", "worse@"] }) - expect("errors" in current.form && current.form.errors).toEqual({ "1": "Please enter a valid email address" }) - }), + message(Message.ClickedRemoveEmail({ rowId: 0 })), + model((current) => + expect(inviteRows(current)).toEqual([ + { id: 1, email: "ok@hazel.test", error: Option.none() }, + { id: 2, email: "worse@", error: Option.some("InvalidEmail") }, + ]), + ), ) }) test("an invited member finalizes with their member id and lands in the org", () => { - const role = withForm(onboardingAt("role", invitedMembership), StepForm.Choice({ box: ChoiceBox.init({ id: "role", selectedKeys: ["developer"] }) })) + const role = withStep( + onboardingAt("role", invitedMembership), + StepState.Role({ box: ChoiceBox.init({ id: "role", selectedKeys: ["developer"] }) }), + ) story( run, given(role), message(Message.ClickedContinueChoice()), - Command.expectExact(urlFor("finalization"), CompleteOnboarding({ memberId: invitedMembership.memberId, role: "developer", useCases: [] })), - Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [CompleteOnboarding, Message.SucceededCompleteOnboarding()]), + Command.expectExact( + urlFor("finalization"), + CompleteOnboarding({ memberId: invitedMembership.memberId, role: "developer", useCases: [] }), + ), + Command.resolveAll( + [ReplaceOnboardingStepUrl, urlDone], + [CompleteOnboarding, Message.SucceededCompleteOnboarding()], + ), Command.expectHas(LoadOnboardingHome({ href: "/hazel" })), Command.resolveAll([ReplaceOnboardingStepUrl, urlDone], [LoadOnboardingHome, Message.CompletedLoadHome()]), ) }) + + test("finishing the flow is not cut short by user.me turning onboarded", () => { + const finalizing = withStep(invite, StepState.Finalizing({ failure: Option.none() })) + expect(sharedChanged(finalizing, makeShared()).outMessage).toBeUndefined() + }) }) diff --git a/apps/web-foldkit/src/page/onboarding/timezone/view.ts b/apps/web-foldkit/src/page/onboarding/timezone/view.ts index 9e7bf2eb0..5144af699 100644 --- a/apps/web-foldkit/src/page/onboarding/timezone/view.ts +++ b/apps/web-foldkit/src/page/onboarding/timezone/view.ts @@ -3,7 +3,7 @@ import { IconMagnifier3, IconMapPin } from "../../../icons" import { button } from "../../../ui/button" import { input, inputGroup } from "../../../ui/input" import { Message } from "../message" -import type { StepForm } from "../model" +import type { StepOf } from "../model" import { onboardingNavigation, stepHeader } from "../navigation" import { cityCard } from "./city-card" import { cityFor, filterCities } from "./data" @@ -12,7 +12,7 @@ import { timeRibbon } from "./ribbon" /** `timezone-selection-step.tsx` */ -type TimezoneForm = Extract<StepForm, { _tag: "Timezone" }> +type TimezoneForm = StepOf<"Timezone"> const toQuery = (value: string) => Message.ChangedTimezoneQuery({ value }) diff --git a/apps/web-foldkit/src/page/onboarding/transition.ts b/apps/web-foldkit/src/page/onboarding/transition.ts index 5d6367b30..04c4692a1 100644 --- a/apps/web-foldkit/src/page/onboarding/transition.ts +++ b/apps/web-foldkit/src/page/onboarding/transition.ts @@ -1,23 +1,24 @@ -import { Match } from "effect" +import { Match, Option } from "effect" import type { Command } from "foldkit" import { modifyFields } from "foldkit/struct" -import type { HazelRpc } from "../../rpc" import { onboardingHref } from "../../route" +import type { Resources } from "../../rpc" import * as ChoiceBox from "../../ui/choice-box" import type { PageReturn, Shared } from "../contract" import { CompleteOnboarding, ReplaceOnboardingStepUrl } from "./command" import { type Direction, nextStep, previousStep, type Step, stepFromUrl } from "./flow" import type { Message } from "./message" -import { type Data, type Model, StepForm } from "./model" +import { type Data, type Membership, type Model, Phase, type Running, StepState, isBusy, stepOf } from "./model" -/** Step changes: each entered step gets fresh form state, like the legacy step components remounting. */ +/** Step changes: each entered step gets fresh state, like the legacy step components remounting. */ export type Return = PageReturn<Model, Message> -const formFor = (model: Model, step: Step, shared: Shared): StepForm => +const stateFor = (step: Step, running: Running, shared: Shared): StepState => Match.value(step).pipe( + Match.when("welcome", () => StepState.Welcome()), Match.when("profileInfo", () => - StepForm.Profile({ + StepState.Profile({ firstName: shared.currentUser?.firstName ?? "", lastName: shared.currentUser?.lastName ?? "", hasChanged: false, @@ -25,8 +26,8 @@ const formFor = (model: Model, step: Step, shared: Shared): StepForm => }), ), Match.when("timezoneSelection", () => - StepForm.Timezone({ - selected: model.data.timezone ?? model.browserTimezone ?? "UTC", + StepState.Timezone({ + selected: running.data.timezone ?? (running.flow.browserTimezone || "UTC"), query: "", debouncedQuery: "", hoveredOffset: null, @@ -34,50 +35,56 @@ const formFor = (model: Model, step: Step, shared: Shared): StepForm => isSubmitting: false, }), ), - Match.when("themeSelection", () => StepForm.Theme()), + Match.when("themeSelection", () => StepState.Theme()), Match.when("useCases", () => - StepForm.Choice({ - box: ChoiceBox.init({ id: "team-size", selectedKeys: model.data.useCases.slice(0, 1) }), + StepState.UseCases({ + box: ChoiceBox.init({ id: "team-size", selectedKeys: running.data.useCases.slice(0, 1) }), }), ), Match.when("role", () => - StepForm.Choice({ - box: ChoiceBox.init({ id: "role", selectedKeys: model.data.role ? [model.data.role] : [] }), + StepState.Role({ + box: ChoiceBox.init({ id: "role", selectedKeys: running.data.role ? [running.data.role] : [] }), }), ), - Match.when("teamInvitation", () => StepForm.Invite({ emails: [""], errors: {}, isLoading: false })), - Match.orElse(() => StepForm.None()), + Match.when("teamInvitation", () => + StepState.Invite({ rows: [{ id: 0, email: "", error: Option.none() }], nextRowId: 1, isSending: false }), + ), + Match.when("finalization", () => StepState.Finalizing({ failure: Option.none() })), + Match.when("completed", () => StepState.Completed()), + Match.exhaustive, ) -const stepCommands = (model: Model, step: Step): ReadonlyArray<Command.Command<Message, never, HazelRpc>> => - step === "finalization" - ? [ - CompleteOnboarding({ - memberId: model.membership?.memberId ?? null, - role: model.data.role, - useCases: model.data.useCases, - }), - ] - : [] +/** Finalizes with what the steps collected and the member the user joined as. */ +export const completeOnboarding = (running: Running): Command.Command<Message, never, Resources> => + CompleteOnboarding({ + memberId: running.flow.membership?.memberId ?? null, + role: running.data.role, + useCases: running.data.useCases, + }) + +export const withPhase = (model: Model, phase: Running): Model => modifyFields(model, { phase: () => phase }) + +/** Replaces the step's state in place (no step change, no URL change). */ +export const setStep = (model: Model, running: Running, step: StepState): Model => + withPhase(model, modifyFields(running, { step: () => step })) /** Enters `step`; `syncUrl` is false only for the first step, which came from the URL. */ export const enterStep = ( model: Model, + running: Running, step: Step, options: { readonly direction: Direction; readonly shared: Shared; readonly syncUrl: boolean }, ): Return => { - const next = modifyFields(model, { - step: () => step, + const next = modifyFields(running, { + step: () => stateFor(step, running, options.shared), direction: () => options.direction, animatesStep: () => options.syncUrl, - error: () => null, - form: () => formFor(model, step, options.shared), }) return { - model: next, + model: withPhase(model, next), commands: [ ...(options.syncUrl ? [ReplaceOnboardingStepUrl({ href: onboardingHref(model.orgId, step) })] : []), - ...stepCommands(next, step), + ...(step === "finalization" ? [completeOnboarding(next)] : []), ], } } @@ -85,40 +92,51 @@ export const enterStep = ( type DataTransform = Partial<{ readonly [K in keyof Data]: (value: Data[K]) => Data[K] }> /** `createStepHandler`: record the step's data and move forward. */ -export const advance = (model: Model, shared: Shared, transform: DataTransform = {}): Return => +export const advance = (model: Model, running: Running, shared: Shared, transform: DataTransform = {}): Return => enterStep( - modifyFields(model, { data: (data) => modifyFields(data, transform) }), - nextStep(model.step, model.userType), + model, + modifyFields(running, { data: (data) => modifyFields(data, transform) }), + nextStep(stepOf(running.step), running.flow.userType), { direction: "forward", shared, syncUrl: true }, ) /** The invite step skips straight to finalization (`handleTeamInviteContinue` / `Skip`). */ -export const finalize = (model: Model, shared: Shared, emails: ReadonlyArray<string>): Return => - enterStep(modifyFields(model, { data: (data) => modifyFields(data, { emails: () => emails }) }), "finalization", { - direction: "forward", - shared, - syncUrl: true, - }) +export const finalize = (model: Model, running: Running, shared: Shared): Return => + enterStep(model, running, "finalization", { direction: "forward", shared, syncUrl: true }) -export const goBack = (model: Model, shared: Shared): Return => { - const previous = previousStep(model.step, model.userType) +/** Back waits for a request in flight: its late result would otherwise land on a fresh form. */ +export const goBack = (model: Model, running: Running, shared: Shared): Return => { + if (isBusy(running.step)) return { model } + const previous = previousStep(stepOf(running.step), running.flow.userType) return previous === null ? { model } - : enterStep(model, previous, { direction: "backward", shared, syncUrl: true }) + : enterStep(model, running, previous, { direction: "backward", shared, syncUrl: true }) } /** - * `createInitialState` plus the `?step=` override, once both the URL and the membership are known. - * An organization with a slug means the user was invited into it. + * `createInitialState` plus the `?step=` override, once both the browser timezone and the membership + * are known. An organization with a slug means the user was invited into it. */ -export const initializeWhenReady = (model: Model, shared: Shared): Return => { - if (model.isInitialized || model.browserTimezone === undefined || model.membership === undefined) - return { model } - const userType = model.membership?.slug ? "invited" : "creator" - const step = stepFromUrl(model.urlStep, userType) ?? "welcome" - return enterStep(modifyFields(model, { isInitialized: () => true, userType: () => userType }), step, { - direction: "forward", - shared, - syncUrl: false, +export const startWhenReady = ( + model: Model, + known: { readonly membership: Option.Option<Membership | null>; readonly browserTimezone: Option.Option<string> }, + shared: Shared, +): Return => + Option.match(Option.all({ membership: known.membership, browserTimezone: known.browserTimezone }), { + onNone: () => ({ model: modifyFields(model, { phase: () => Phase.Starting(known) }) }), + onSome: ({ membership, browserTimezone }) => { + const userType = membership?.slug ? "invited" : "creator" + const running = Phase.Running({ + flow: { userType, membership, browserTimezone }, + step: StepState.Welcome(), + direction: "forward", + animatesStep: false, + data: { timezone: null, useCases: [], role: null }, + }) + return enterStep(model, running, stepFromUrl(model.urlStep, userType) ?? "welcome", { + direction: "forward", + shared, + syncUrl: false, + }) + }, }) -} diff --git a/apps/web-foldkit/src/page/onboarding/update.ts b/apps/web-foldkit/src/page/onboarding/update.ts index 503e5fcea..494ff387f 100644 --- a/apps/web-foldkit/src/page/onboarding/update.ts +++ b/apps/web-foldkit/src/page/onboarding/update.ts @@ -2,12 +2,13 @@ import { Theme } from "@hazel/domain/models" import { Option, Schema } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" -import { errorToast, successToast, warningToast } from "../../data/actions" +import { failureMessage } from "~/lib/error-messages" +import { errorToast, errorToastOf, successToast, warningToast } from "../../data/actions" import type { ToastRequest } from "../../overlay/toasts" -import * as Interaction from "../../ui/aria/interaction" -import * as ChoiceBox from "../../ui/choice-box" import { AppRoute, hrefOf, type RouteOf } from "../../route" import type { ThemeCustomization, ThemeMode } from "../../theme" +import * as Interaction from "../../ui/aria/interaction" +import * as ChoiceBox from "../../ui/choice-box" import type { Shared } from "../contract" import { PageOutMessage } from "../out-message" import { @@ -19,18 +20,44 @@ import { UpdateUserTimezone, } from "./command" import { Message } from "./message" -import { type Model, StepForm } from "./model" +import { + type Model, + Phase, + type Running, + type StepOf, + StepState, + type UpdateUserFailure, +} from "./model" import { CITIES } from "./timezone/data" -import { advance, enterStep, finalize, goBack, initializeWhenReady, type Return } from "./transition" +import { + advance, + completeOnboarding, + enterStep, + finalize, + goBack, + type Return, + setStep, + startWhenReady, + withPhase, +} from "./transition" +import { filledEmails, inviteRowError, isProfileValid, MAX_INVITES } from "./validation" const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) -/** The reverse guard: an onboarded user who lands here goes to the app root. */ +/** Before the flow runs, or while it still can: finishing it must not race a redirect. */ +const canRedirect = (model: Model): boolean => + Phase.match(model.phase, { + Starting: () => true, + Leaving: () => false, + Running: ({ step }) => step._tag !== "Finalizing" && step._tag !== "Completed", + }) + +/** The reverse guard: an onboarded user who lands here goes to the app root, once. */ const redirectIfOnboarded = (model: Model, shared: Shared): Return => - shared.currentUser?.isOnboarded && !model.hasRedirected + shared.currentUser?.isOnboarded && canRedirect(model) ? { - model: modifyFields(model, { hasRedirected: () => true }), - outMessage: PageOutMessage.RequestedNavigation({ href: "/", replace: false }), + model: modifyFields(model, { phase: () => Phase.Leaving() }), + outMessage: PageOutMessage.RequestedNavigation({ href: hrefOf(AppRoute.Root()), replace: false }), } : { model } @@ -38,37 +65,45 @@ export const init = (route: RouteOf<"Onboarding">, shared: Shared): Return => { const model: Model = { urlStep: Option.getOrNull(route.step), orgId: Option.getOrNull(route.orgId), - membership: undefined, - isInitialized: false, - step: "welcome", - direction: "forward", - animatesStep: false, - userType: "creator", - data: { timezone: null, useCases: [], role: null, emails: [] }, - form: StepForm.None(), - error: null, - browserTimezone: undefined, - hasRedirected: false, + phase: Phase.Starting({ membership: Option.none(), browserTimezone: Option.none() }), interaction: Interaction.init(), } const redirected = redirectIfOnboarded(model, shared) return { ...redirected, commands: [ReadBrowserTimezone()] } } +const isStep = <Tag extends StepState["_tag"]>(step: StepState, tag: Tag): step is StepOf<Tag> => + step._tag === tag + +/** Step Messages apply only while their step is showing. */ +const withStep = <Tag extends StepState["_tag"]>( + model: Model, + tag: Tag, + f: (step: StepOf<Tag>, running: Running) => Return, +): Return => { + const { phase } = model + return phase._tag === "Running" && isStep(phase.step, tag) ? f(phase.step, phase) : { model } +} + +const withRunning = (model: Model, f: (running: Running) => Return): Return => + model.phase._tag === "Running" ? f(model.phase) : { model } + /** The profile defaults come from `user.me`, which may arrive after the step was entered. */ export const sharedChanged = (model: Model, shared: Shared): Return => { - const { form } = model - const seeded = - form._tag === "Profile" && !form.hasChanged && shared.currentUser && !form.firstName && !form.lastName - ? modifyFields(model, { - form: () => - StepForm.Profile({ + const seeded = withStep(model, "Profile", (form, running) => ({ + model: + !form.hasChanged && shared.currentUser && !form.firstName && !form.lastName + ? setStep( + model, + running, + StepState.Profile({ ...form, - firstName: shared.currentUser?.firstName ?? "", - lastName: shared.currentUser?.lastName ?? "", + firstName: shared.currentUser.firstName, + lastName: shared.currentUser.lastName, }), - }) - : model + ) + : model, + })).model return redirectIfOnboarded(seeded, shared) } @@ -80,126 +115,137 @@ const requestTheme = (model: Model, mode: ThemeMode, customization: ThemeCustomi outMessage: PageOutMessage.RequestedTheme({ preference: { mode, customization } }), }) -const hasTag = <Tag extends StepForm["_tag"]>( - form: StepForm, - tag: Tag, -): form is Extract<StepForm, { _tag: Tag }> => form._tag === tag - -/** Step Messages apply only while their step's form is showing. */ -const withForm = <Tag extends StepForm["_tag"]>( - model: Model, - tag: Tag, - f: (form: Extract<StepForm, { _tag: Tag }>) => Return, -): Return => { - const form = model.form - return hasTag(form, tag) ? f(form) : { model } -} - -const setForm = (model: Model, form: StepForm): Model => modifyFields(model, { form: () => form }) +const updateUserFailureToast = (reason: UpdateUserFailure) => + errorToastOf( + failureMessage(reason, { + UserNotFound: { + title: "User not found", + description: "Your account could not be found. Please try signing in again.", + }, + }), + ) -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const plural = (count: number) => `${count} invitation${count > 1 ? "s" : ""}` export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { DetectedBrowserTimezone: ({ browserTimezone }) => - initializeWhenReady(modifyFields(model, { browserTimezone: () => browserTimezone }), shared), + model.phase._tag === "Starting" + ? startWhenReady(model, { ...model.phase, browserTimezone: Option.some(browserTimezone) }, shared) + : { model }, + // A running flow keeps its user type; the membership itself stays current. UpdatedMembership: ({ membership }) => - initializeWhenReady(modifyFields(model, { membership: () => membership }), shared), - ClickedBack: () => goBack(model, shared), - ClickedGetStarted: () => advance(model, shared), + Phase.match(model.phase, { + Starting: (starting) => + startWhenReady(model, { ...starting, membership: Option.some(membership) }, shared), + Leaving: () => ({ model }), + Running: (running) => ({ + model: withPhase( + model, + modifyFields(running, { flow: (flow) => modifyFields(flow, { membership: () => membership }) }), + ), + }), + }), + ClickedBack: () => withRunning(model, (running) => goBack(model, running, shared)), + ClickedGetStarted: () => withStep(model, "Welcome", (_, running) => advance(model, running, shared)), ChangedFirstName: ({ value }) => - withForm(model, "Profile", (form) => ({ - model: setForm(model, StepForm.Profile({ ...form, firstName: value, hasChanged: true })), + withStep(model, "Profile", (form, running) => ({ + model: setStep(model, running, StepState.Profile({ ...form, firstName: value, hasChanged: true })), })), ChangedLastName: ({ value }) => - withForm(model, "Profile", (form) => ({ - model: setForm(model, StepForm.Profile({ ...form, lastName: value, hasChanged: true })), + withStep(model, "Profile", (form, running) => ({ + model: setStep(model, running, StepState.Profile({ ...form, lastName: value, hasChanged: true })), })), SubmittedProfile: () => - withForm(model, "Profile", (form) => { + withStep(model, "Profile", (form, running) => { if (form.isSubmitting) return { model } - const firstName = form.firstName.trim() - const lastName = form.lastName.trim() const userId = shared.currentUser?.id - return !firstName || !lastName || userId === undefined - ? { model: setForm(model, StepForm.Profile({ ...form, hasChanged: true })) } + return !isProfileValid(form) || userId === undefined + ? { model: setStep(model, running, StepState.Profile({ ...form, hasChanged: true })) } : { - model: setForm(model, StepForm.Profile({ ...form, isSubmitting: true })), - commands: [UpdateProfile({ userId, firstName, lastName })], + model: setStep(model, running, StepState.Profile({ ...form, isSubmitting: true })), + commands: [ + UpdateProfile({ + userId, + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + }), + ], } }), - SucceededUpdateProfile: () => withForm(model, "Profile", () => advance(model, shared)), - FailedUpdateProfile: () => - withForm(model, "Profile", (form) => ({ - model: setForm(model, StepForm.Profile({ ...form, isSubmitting: false })), - outMessage: toast(errorToast("Failed to update profile")), + SucceededUpdateProfile: () => + withStep(model, "Profile", (form, running) => + form.isSubmitting ? advance(model, running, shared) : { model }, + ), + FailedUpdateProfile: ({ reason }) => + withStep(model, "Profile", (form, running) => ({ + model: setStep(model, running, StepState.Profile({ ...form, isSubmitting: false })), + outMessage: toast(updateUserFailureToast(reason)), })), ChangedTimezoneQuery: ({ value }) => - withForm(model, "Timezone", (form) => ({ - model: setForm(model, StepForm.Timezone({ ...form, query: value })), + withStep(model, "Timezone", (form, running) => ({ + model: setStep(model, running, StepState.Timezone({ ...form, query: value })), commands: [DebounceTimezoneQuery({ query: value })], })), ElapsedTimezoneDebounce: ({ query }) => - withForm(model, "Timezone", (form) => ({ + withStep(model, "Timezone", (form, running) => ({ model: form.query === query - ? setForm(model, StepForm.Timezone({ ...form, debouncedQuery: query })) + ? setStep(model, running, StepState.Timezone({ ...form, debouncedQuery: query })) : model, })), ClickedCity: ({ timezone }) => - withForm(model, "Timezone", (form) => ({ - model: setForm(model, StepForm.Timezone({ ...form, selected: timezone })), + withStep(model, "Timezone", (form, running) => ({ + model: setStep(model, running, StepState.Timezone({ ...form, selected: timezone })), })), ClickedOffset: ({ offset }) => - withForm(model, "Timezone", (form) => { + withStep(model, "Timezone", (form, running) => { const city = CITIES.find((candidate) => candidate.offset === offset) return { model: city - ? setForm(model, StepForm.Timezone({ ...form, selected: city.timezone })) + ? setStep(model, running, StepState.Timezone({ ...form, selected: city.timezone })) : model, } }), HoveredOffset: ({ offset }) => - withForm(model, "Timezone", (form) => ({ - model: setForm(model, StepForm.Timezone({ ...form, hoveredOffset: offset })), + withStep(model, "Timezone", (form, running) => ({ + model: setStep(model, running, StepState.Timezone({ ...form, hoveredOffset: offset })), })), ClickedDetectTimezone: () => - withForm(model, "Timezone", (form) => ({ - model: setForm( + withStep(model, "Timezone", (form, running) => ({ + model: setStep( model, - StepForm.Timezone({ + running, + StepState.Timezone({ ...form, detectionAttempted: true, - selected: model.browserTimezone || form.selected, + selected: running.flow.browserTimezone || form.selected, }), ), })), ClickedContinueTimezone: () => - withForm(model, "Timezone", (form) => + withStep(model, "Timezone", (form, running) => form.isSubmitting || form.selected === null || shared.currentUser === null ? { model } : { - model: setForm(model, StepForm.Timezone({ ...form, isSubmitting: true })), - commands: [ - UpdateUserTimezone({ - userId: shared.currentUser.id, - timezone: form.selected, - }), - ], + model: setStep(model, running, StepState.Timezone({ ...form, isSubmitting: true })), + commands: [UpdateUserTimezone({ userId: shared.currentUser.id, timezone: form.selected })], }, ), SucceededUpdateTimezone: ({ timezone }) => - withForm(model, "Timezone", () => advance(model, shared, { timezone: () => timezone })), - FailedUpdateTimezone: ({ toast: request }) => - withForm(model, "Timezone", (form) => ({ - model: setForm(model, StepForm.Timezone({ ...form, isSubmitting: false })), - outMessage: toast(request), + withStep(model, "Timezone", (form, running) => + form.isSubmitting ? advance(model, running, shared, { timezone: () => timezone }) : { model }, + ), + FailedUpdateTimezone: ({ reason }) => + withStep(model, "Timezone", (form, running) => ({ + model: setStep(model, running, StepState.Timezone({ ...form, isSubmitting: false })), + outMessage: toast(updateUserFailureToast(reason)), })), SelectedBrandColor: ({ hex }) => - withForm(model, "Theme", () => + withStep(model, "Theme", () => Option.match(decodeHexColor(hex), { onNone: () => ({ model }), onSome: (primary) => @@ -207,130 +253,147 @@ export const update = (model: Model, message: Message, shared: Shared): Return = }), ), SelectedTheme: ({ theme }) => - withForm(model, "Theme", () => requestTheme(model, theme, shared.theme.customization)), - ClickedContinueTheme: () => withForm(model, "Theme", () => advance(model, shared)), + withStep(model, "Theme", () => requestTheme(model, theme, shared.theme.customization)), + ClickedContinueTheme: () => withStep(model, "Theme", (_, running) => advance(model, running, shared)), GotChoiceBoxMessage: ({ message: boxMessage }) => - withForm(model, "Choice", (form) => { - const result = ChoiceBox.update(form.box, boxMessage) + withRunning(model, (running) => { + const { step } = running + if (step._tag !== "UseCases" && step._tag !== "Role") return { model } + const result = ChoiceBox.update(step.box, boxMessage) return { - model: setForm(model, StepForm.Choice({ box: result.model })), + model: setStep( + model, + running, + step._tag === "UseCases" + ? StepState.UseCases({ box: result.model }) + : StepState.Role({ box: result.model }), + ), commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotChoiceBoxMessage({ message }), ), } }), ClickedContinueChoice: () => - withForm(model, "Choice", (form) => { - const [selected] = form.box.selectedKeys + withRunning(model, (running) => { + const { step } = running + if (step._tag !== "UseCases" && step._tag !== "Role") return { model } + const [selected] = step.box.selectedKeys if (selected === undefined) return { model } - return model.step === "useCases" - ? advance(model, shared, { useCases: () => [selected] }) - : advance(model, shared, { role: () => selected }) + return step._tag === "UseCases" + ? advance(model, running, shared, { useCases: () => [selected] }) + : advance(model, running, shared, { role: () => selected }) }), - ChangedEmail: ({ index, value }) => - withForm(model, "Invite", (form) => { - const { [String(index)]: _cleared, ...errors } = form.errors - return { - model: setForm( - model, - StepForm.Invite({ - ...form, - emails: form.emails.map((email, i) => (i === index ? value : email)), - errors, - }), - ), - } - }), - ClickedAddEmail: () => - withForm(model, "Invite", (form) => ({ - model: setForm(model, StepForm.Invite({ ...form, emails: [...form.emails, ""] })), - })), - ClickedRemoveEmail: ({ index }) => - withForm(model, "Invite", (form) => { - const emails = form.emails.filter((_, i) => i !== index) - // Errors are keyed by row index, so rows after the removed one move up by one. - const errors = Object.fromEntries( - Object.entries(form.errors).flatMap(([key, error]) => { - const row = Number(key) - return row === index ? [] : [[String(row > index ? row - 1 : row), error]] + ChangedEmail: ({ rowId, value }) => + withStep(model, "Invite", (form, running) => ({ + model: setStep( + model, + running, + StepState.Invite({ + ...form, + rows: form.rows.map((row) => + row.id === rowId ? { id: row.id, email: value, error: Option.none() } : row, + ), }), - ) + ), + })), + ClickedAddEmail: () => + withStep(model, "Invite", (form, running) => + form.rows.length >= MAX_INVITES + ? { model } + : { + model: setStep( + model, + running, + StepState.Invite({ + ...form, + rows: [...form.rows, { id: form.nextRowId, email: "", error: Option.none() }], + nextRowId: form.nextRowId + 1, + }), + ), + }, + ), + ClickedRemoveEmail: ({ rowId }) => + withStep(model, "Invite", (form, running) => { + const rows = form.rows.filter((row) => row.id !== rowId) return { - model: setForm( + model: setStep( model, - StepForm.Invite({ ...form, emails: emails.length > 0 ? emails : [""], errors }), + running, + rows.length > 0 + ? StepState.Invite({ ...form, rows }) + : StepState.Invite({ + ...form, + rows: [{ id: form.nextRowId, email: "", error: Option.none() }], + nextRowId: form.nextRowId + 1, + }), ), } }), ClickedContinueInvite: () => - withForm(model, "Invite", (form) => { - if (form.isLoading) return { model } - const filled = form.emails.filter((email) => email.trim().length > 0) - if (filled.length === 0) return finalize(model, shared, []) - // Keyed by row index, which is how the view reads them. - const errors = Object.fromEntries( - form.emails.flatMap((email, index) => - email.trim().length === 0 || EMAIL_PATTERN.test(email) - ? [] - : [[String(index), "Please enter a valid email address"]], - ), - ) - return Object.keys(errors).length > 0 - ? { model: setForm(model, StepForm.Invite({ ...form, errors })) } + withStep(model, "Invite", (form, running) => { + if (form.isSending) return { model } + const emails = filledEmails(form.rows) + if (emails.length === 0) return finalize(model, running, shared) + const rows = form.rows.map((row) => ({ ...row, error: inviteRowError(row.email) })) + if (rows.some((row) => Option.isSome(row.error))) + return { model: setStep(model, running, StepState.Invite({ ...form, rows })) } + const organizationId = running.flow.membership?.organizationId ?? model.orgId + return organizationId === null + ? { model, outMessage: toast(errorToast("No active organization")) } : { - model: setForm(model, StepForm.Invite({ ...form, isLoading: true })), - commands: [ - SendInvites({ - organizationId: model.membership?.organizationId ?? model.orgId, - emails: filled, - }), - ], + model: setStep(model, running, StepState.Invite({ ...form, isSending: true })), + commands: [SendInvites({ organizationId, emails })], } }), SucceededSendInvites: ({ emails, failedCount }) => - withForm(model, "Invite", () => { - const plural = (count: number) => `${count} invitation${count > 1 ? "s" : ""}` - const next = finalize(model, shared, emails) + withStep(model, "Invite", (form, running) => + !form.isSending + ? { model } + : { + ...finalize(model, running, shared), + outMessage: + failedCount === 0 + ? toast(successToast(`Sent ${plural(emails.length)}`)) + : toast( + warningToast(`Sent ${plural(emails.length - failedCount)}, ${failedCount} failed`), + ), + }, + ), + FailedSendInvites: () => + withStep(model, "Invite", (form, running) => ({ + model: setStep(model, running, StepState.Invite({ ...form, isSending: false })), + outMessage: toast(errorToast("Failed to send invitations")), + })), + + SucceededCompleteOnboarding: () => + withStep(model, "Finalizing", (_, running) => { + const slug = running.flow.membership?.slug + const completed = enterStep(model, running, "completed", { direction: "forward", shared, syncUrl: true }) return { - ...next, - outMessage: - failedCount === 0 - ? toast(successToast(`Sent ${plural(emails.length)}`)) - : toast( - warningToast( - `Sent ${plural(emails.length - failedCount)}, ${failedCount} failed`, - ), - ), + ...completed, + commands: [ + ...(completed.commands ?? []), + LoadOnboardingHome({ + href: hrefOf(slug ? AppRoute.OrgHome({ orgSlug: slug }) : AppRoute.Root()), + }), + ], } }), - FailedSendInvites: ({ reason }) => - withForm(model, "Invite", (form) => ({ - model: setForm(model, StepForm.Invite({ ...form, isLoading: false })), - outMessage: toast( - errorToast( - reason === "NoOrganization" ? "No active organization" : "Failed to send invitations", - ), - ), + FailedCompleteOnboarding: ({ reason }) => + withStep(model, "Finalizing", (_, running) => ({ + model: setStep(model, running, StepState.Finalizing({ failure: Option.some(reason) })), })), - - SucceededCompleteOnboarding: () => { - const slug = model.membership?.slug - const completed = enterStep(model, "completed", { direction: "forward", shared, syncUrl: true }) - return { - ...completed, - commands: [ - ...(completed.commands ?? []), - LoadOnboardingHome({ - href: hrefOf(slug ? AppRoute.OrgHome({ orgSlug: slug }) : AppRoute.Root()), - }), - ], - } - }, - FailedCompleteOnboarding: ({ error }) => ({ - model: modifyFields(model, { error: () => error }), - }), + ClickedRetryFinalize: () => + withStep(model, "Finalizing", ({ failure }, running) => + Option.isNone(failure) + ? { model } + : { + model: setStep(model, running, StepState.Finalizing({ failure: Option.none() })), + commands: [completeOnboarding(running)], + }, + ), CompletedReplaceStepUrl: () => ({ model }), CompletedLoadHome: () => ({ model }), CompletedAutoFocus: () => ({ model }), diff --git a/apps/web-foldkit/src/page/onboarding/validation.ts b/apps/web-foldkit/src/page/onboarding/validation.ts new file mode 100644 index 000000000..ff17b9c2d --- /dev/null +++ b/apps/web-foldkit/src/page/onboarding/validation.ts @@ -0,0 +1,34 @@ +import { Option } from "effect" +import type { InviteRow, InviteRowError } from "./model" + +/** The onboarding forms' rules, read by both `update` (to block a submit) and the views (to show why). */ + +/** `invite-team-step.tsx` sends at most this many at once. */ +export const MAX_INVITES = 10 + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +const isFilled = (value: string) => value.trim().length > 0 + +/** Which names are missing. Trimmed, so " " is missing too. */ +export const profileErrors = (form: { readonly firstName: string; readonly lastName: string }) => ({ + firstName: !isFilled(form.firstName), + lastName: !isFilled(form.lastName), +}) + +export const isProfileValid = (form: { readonly firstName: string; readonly lastName: string }) => { + const errors = profileErrors(form) + return !errors.firstName && !errors.lastName +} + +/** An empty row is skipped, not an error. */ +export const inviteRowError = (email: string): Option.Option<InviteRowError> => + !isFilled(email) || EMAIL_PATTERN.test(email.trim()) ? Option.none() : Option.some("InvalidEmail") + +/** The addresses a send would invite, trimmed. */ +export const filledEmails = (rows: ReadonlyArray<InviteRow>): ReadonlyArray<string> => + rows.flatMap((row) => (isFilled(row.email) ? [row.email.trim()] : [])) + +/** At least one row holds a valid address (the Continue button then reads "Send invites"). */ +export const hasSendableEmails = (rows: ReadonlyArray<InviteRow>) => + rows.some((row) => isFilled(row.email) && Option.isNone(inviteRowError(row.email))) diff --git a/apps/web-foldkit/src/page/onboarding/view.ts b/apps/web-foldkit/src/page/onboarding/view.ts index 3741f77e8..70f5e9593 100644 --- a/apps/web-foldkit/src/page/onboarding/view.ts +++ b/apps/web-foldkit/src/page/onboarding/view.ts @@ -1,13 +1,16 @@ +import { Option } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { appLoader } from "../../shell/layouts" import { link } from "../../ui/link" +import { failureMessage } from "~/lib/error-messages" +import { button } from "../../ui/button" import { loader } from "../../ui/loader" import type { PageViewInputs, Shared } from "../contract" import { backgroundImage, logoContent, panelCard, panelFrame } from "./brand-panel" import { stepNumber, totalSteps } from "./flow" import { Message, toInteractionMessage } from "./message" -import type { Model } from "./model" +import { type Model, Phase, type Running, type StepOf, StepState, stepOf } from "./model" import { enterAnimation } from "./enter-animation" import { MOTION_END_STYLE } from "./motion" import { roleStep, useCaseStep } from "./steps/choice" @@ -19,23 +22,26 @@ import { timezoneStep } from "./timezone/view" /** `routes/_app/onboarding/index.tsx` inside `onboarding-layout.tsx`. */ -const stepCounter = (h: HtmlBuilder<Message>, model: Model): Array<Html | string> => [ - "Step", - " ", - h.span( - [ - h.Key(`step-number-${stepNumber(model.step, model.userType)}`), - h.Class("inline-block font-mono"), - ...enterAnimation(h, model.direction === "forward" ? "NumberForward" : "NumberBackward"), - ], - [String(stepNumber(model.step, model.userType))], - ), - " ", - "of ", - String(totalSteps(model.userType)), -] +const stepCounter = (h: HtmlBuilder<Message>, running: Running): Array<Html | string> => { + const number = stepNumber(stepOf(running.step), running.flow.userType) + return [ + "Step", + " ", + h.span( + [ + h.Key(`step-number-${number}`), + h.Class("inline-block font-mono"), + ...enterAnimation(h, running.direction === "forward" ? "NumberForward" : "NumberBackward"), + ], + [String(number)], + ), + " ", + "of ", + String(totalSteps(running.flow.userType)), + ] +} -const leftPanel = (h: HtmlBuilder<Message>, model: Model, nowMs: number): Html => +const leftPanel = (h: HtmlBuilder<Message>, running: Running, nowMs: number): Html => panelFrame(h, [ backgroundImage(h, nowMs, "Onboarding background"), link( @@ -55,66 +61,81 @@ const leftPanel = (h: HtmlBuilder<Message>, model: Model, nowMs: number): Html = [h.Class("text-lg text-white")], ["Welcome to your new workspace. Let's get you set up in just a few steps."], ), - h.div([h.Class("font-mono text-sm text-white/80")], stepCounter(h, model)), + h.div([h.Class("font-mono text-sm text-white/80")], stepCounter(h, running)), ], ), ]), ]) -const finalizationView = (h: HtmlBuilder<Message>, error: string | null): Html => - h.div( - [h.Class("flex flex-col items-center justify-center space-y-4 py-12 text-center")], - [ - loader(h, { className: "size-12" }), - h.p([h.Class("font-medium text-lg")], ["Setting up your workspace..."]), - h.p([h.Class("text-muted-fg text-sm")], ["This will just take a moment"]), - ...(error ? [h.p([h.Class("text-danger text-sm")], [error])] : []), - ], - ) +/** Setting up, or (after a failed finalize) why it stopped and a way to try again. No spinner while failed. */ +const finalizationView = (h: HtmlBuilder<Message>, step: StepOf<"Finalizing">): Html => + Option.match(step.failure, { + onNone: () => + h.div( + [h.Class("flex flex-col items-center justify-center space-y-4 py-12 text-center")], + [ + loader(h, { className: "size-12" }), + h.p([h.Class("font-medium text-lg")], ["Setting up your workspace..."]), + h.p([h.Class("text-muted-fg text-sm")], ["This will just take a moment"]), + ], + ), + onSome: (reason) => { + const message = failureMessage(reason, {}) + return h.div( + [h.Class("flex flex-col items-center justify-center space-y-4 py-12 text-center")], + [ + h.p([h.Class("font-medium text-lg")], ["Couldn't finish setting up your workspace"]), + h.p( + [h.Class("text-danger text-sm"), h.Role("alert")], + [message.description === undefined ? message.title : `${message.title}. ${message.description}`], + ), + button(h, { intent: "primary", onPress: Message.ClickedRetryFinalize() }, ["Try again"]), + ], + ) + }, + }) -const stepBody = (h: HtmlBuilder<Message>, model: Model, shared: Shared): Html | null => { - const { form } = model - if (model.step === "welcome") - return welcomeStep(h, { - isCreatingOrg: model.userType === "creator", - organizationName: model.membership?.name, - }) - if (model.step === "finalization") return finalizationView(h, model.error) +const stepBody = (h: HtmlBuilder<Message>, model: Model, running: Running, shared: Shared): Html => { const interaction = { model: model.interaction, toParentMessage: toInteractionMessage } - if (form._tag === "Profile") return profileStep(h, form, interaction) - if (form._tag === "Timezone") - return timezoneStep(h, form, { browserTimezone: model.browserTimezone ?? "UTC", nowMs: shared.nowMs }) - if (form._tag === "Theme") return themeStep(h, shared.theme) - if (form._tag === "Choice") - return model.step === "useCases" ? useCaseStep(h, form.box) : roleStep(h, form.box) - if (form._tag === "Invite") return inviteStep(h, form, interaction) - return null + return StepState.match(running.step, { + Welcome: () => + welcomeStep(h, { + isCreatingOrg: running.flow.userType === "creator", + organizationName: running.flow.membership?.name, + }), + Profile: (form) => profileStep(h, form, interaction), + Timezone: (form) => + timezoneStep(h, form, { browserTimezone: running.flow.browserTimezone || "UTC", nowMs: shared.nowMs }), + Theme: () => themeStep(h, shared.theme), + UseCases: ({ box }) => useCaseStep(h, box), + Role: ({ box }) => roleStep(h, box), + Invite: (form) => inviteStep(h, form, interaction), + Finalizing: (step) => finalizationView(h, step), + Completed: () => finalizationView(h, StepState.Finalizing({ failure: Option.none() })), + }) } /** The motion.div around each step, keyed by step so a new step enters with its transition. */ -const animatedStep = (h: HtmlBuilder<Message>, model: Model, body: Html): Html => +const animatedStep = (h: HtmlBuilder<Message>, running: Running, body: Html): Html => h.div( [ - h.Key(`step-${model.step}`), - ...(model.animatesStep - ? enterAnimation(h, model.direction === "forward" ? "StepForward" : "StepBackward") + h.Key(`step-${stepOf(running.step)}`), + ...(running.animatesStep + ? enterAnimation(h, running.direction === "forward" ? "StepForward" : "StepBackward") : [h.Attribute("style", MOTION_END_STYLE)]), ], [body], ) -export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => { - // `AppShell` waits for `user.me`; onboarding also waits for its flow to be known. - if (!model.isInitialized || shared.currentUser === null) return appLoader(h) - const body = stepBody(h, model, shared) - return h.main( +const running = (h: HtmlBuilder<Message>, model: Model, running: Running, shared: Shared): Html => + h.main( [ h.Class( "relative grid h-dvh grid-cols-1 flex-col items-center justify-center overflow-y-auto lg:max-w-none lg:grid-cols-2 lg:overflow-hidden", ), ], [ - leftPanel(h, model, shared.nowMs), + leftPanel(h, running, shared.nowMs), h.div( [ h.Class( @@ -124,10 +145,20 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, [ h.div( [h.Class("m-auto w-full max-w-2xl space-y-4 py-4 sm:space-y-6 sm:py-6")], - body === null ? [] : [animatedStep(h, model, body)], + [animatedStep(h, running, stepBody(h, model, running, shared))], ), ], ), ], ) -}) + +export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => + // `AppShell` waits for `user.me`; onboarding also waits for its flow to be known. + shared.currentUser === null + ? appLoader(h) + : Phase.match(model.phase, { + Starting: () => appLoader(h), + Leaving: () => appLoader(h), + Running: (phase) => running(h, model, phase, shared), + }), +) diff --git a/apps/web-foldkit/src/test/pages-entry-fixtures.ts b/apps/web-foldkit/src/test/pages-entry-fixtures.ts index 8adb67fcd..dc47453c7 100644 --- a/apps/web-foldkit/src/test/pages-entry-fixtures.ts +++ b/apps/web-foldkit/src/test/pages-entry-fixtures.ts @@ -2,7 +2,7 @@ import { OrganizationId, OrganizationMemberId } from "@hazel/schema" import { Option, Schema } from "effect" import type { Shared } from "../page/contract" import { Message } from "../page/onboarding/message" -import type { Membership, Model, StepForm } from "../page/onboarding/model" +import type { Membership, Model, Running, StepState } from "../page/onboarding/model" import { init, update } from "../page/onboarding/update" import { currentUser, makeShared, uuid } from "./pages-fixtures" @@ -44,5 +44,14 @@ export const onboardingAt = ( Message.UpdatedMembership({ membership }), ].reduce((model, message) => update(model, message, shared).model, init(onboardingRoute(urlStep), shared).model) -/** Replaces the current step's form, for states a test reaches faster by construction. */ -export const withForm = (model: Model, form: StepForm): Model => ({ ...model, form }) +/** The running flow; fails the test when the page is still starting or leaving. */ +export const runningOf = (model: Model): Running => { + if (model.phase._tag !== "Running") throw new Error(`onboarding is ${model.phase._tag}, not Running`) + return model.phase +} + +/** Replaces the current step's state, for states a test reaches faster by construction. */ +export const withStep = (model: Model, step: StepState): Model => ({ + ...model, + phase: { ...runningOf(model), step }, +}) From a3926dcf6d696fc7f8acfc94da7790759bf1e0b7 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:54:47 +0200 Subject: [PATCH 39/52] test(web): shell, sidebar and presence stories for the reshaped models Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../platform/presence/presence.story.test.ts | 132 ++++++++++++++---- .../channels-sidebar/sidebar.story.test.ts | 21 +-- .../web-foldkit/src/shell/shell.story.test.ts | 58 ++++---- 3 files changed, 152 insertions(+), 59 deletions(-) diff --git a/apps/web-foldkit/src/platform/presence/presence.story.test.ts b/apps/web-foldkit/src/platform/presence/presence.story.test.ts index 40b9e7c78..4d309c1b2 100644 --- a/apps/web-foldkit/src/platform/presence/presence.story.test.ts +++ b/apps/web-foldkit/src/platform/presence/presence.story.test.ts @@ -1,10 +1,12 @@ import { ChannelId, UserId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" +import { fromString } from "foldkit/url" import { describe, expect, test } from "vitest" +import { chatChannelIdOf, urlToAppRoute } from "../../route" import { Message } from "./message" -import { AFK_TIMEOUT_MS, channelIdOfPathname, init, type Model } from "./model" -import { BroadcastActivity, SendHeartbeat, SendPresenceUpdate, update } from "./update" +import { AFK_TIMEOUT_MS, init, type Model, type PresenceStatus, syncRetryDelayMs } from "./model" +import { BroadcastActivity, SendHeartbeat, SendPresenceUpdate, update, WaitSyncRetry } from "./update" /** Behavioral parity of `usePresenceEffects`: the initial write, debounced diffs, AFK and heartbeat. */ @@ -17,7 +19,7 @@ const T0 = 1_700_000_000_000 const mount = (activeChannelId: ChannelId | null = general) => Message.ChangedContext({ userId: ada, activeChannelId, nowMs: T0 }) -/** Mounted, with the initial update already written. */ +/** Mounted, with the initial update already confirmed. */ const synced = (overrides: Partial<Model> = {}): Model => ({ ...init(), userId: ada, @@ -28,8 +30,20 @@ const synced = (overrides: Partial<Model> = {}): Model => ({ ...overrides, }) +const snapshot = (status: PresenceStatus, activeChannelId: ChannelId | null) => ({ status, activeChannelId }) + +/** The write for `payload`, holding `snapshot` once it lands. */ +const send = ( + state: { status: PresenceStatus; activeChannelId: ChannelId | null }, + payload: { status?: PresenceStatus; activeChannelId?: ChannelId | null }, +) => SendPresenceUpdate({ userId: ada, snapshot: state, ...payload }) + +const confirmed = (state: { status: PresenceStatus; activeChannelId: ChannelId | null }) => + Message.SucceededSendPresenceUpdate({ userId: ada, sent: state }) + describe("initial update", () => { test("mounting writes status and channel once, after the 300ms debounce", () => { + const state = snapshot("online", general) story( update, given(init()), @@ -37,24 +51,27 @@ describe("initial update", () => { Command.expectNone(), model((m) => expect([m.isSyncPending, m.syncVersion]).toEqual([true, 1])), message(Message.ElapsedSyncDebounce({ version: 1 })), - Command.expectExact(SendPresenceUpdate({ status: "online", activeChannelId: general })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), - model((m) => expect(m.sent).toEqual({ status: "online", activeChannelId: general })), + Command.expectExact(send(state, { status: "online", activeChannelId: general })), + model((m) => expect(m.sent).toBeNull()), + Command.resolve(SendPresenceUpdate, confirmed(state)), + model((m) => expect(m.sent).toEqual(state)), ) }) test("outside a channel the initial update carries a null channel", () => { + const state = snapshot("online", null) story( update, given(init()), message(mount(null)), message(Message.ElapsedSyncDebounce({ version: 1 })), - Command.expectExact(SendPresenceUpdate({ status: "online", activeChannelId: null })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(state, { status: "online", activeChannelId: null })), + Command.resolve(SendPresenceUpdate, confirmed(state)), ) }) test("a change inside the debounce window restarts it; only the latest timer writes", () => { + const state = snapshot("online", random) story( update, given(init()), @@ -63,12 +80,13 @@ describe("initial update", () => { message(Message.ElapsedSyncDebounce({ version: 1 })), Command.expectNone(), message(Message.ElapsedSyncDebounce({ version: 2 })), - Command.expectExact(SendPresenceUpdate({ status: "online", activeChannelId: random })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(state, { status: "online", activeChannelId: random })), + Command.resolve(SendPresenceUpdate, confirmed(state)), ) }) test("leaving the org layout and coming back writes a fresh initial update", () => { + const state = snapshot("online", general) story( update, given(synced()), @@ -76,39 +94,42 @@ describe("initial update", () => { Command.expectNone(), message(mount()), message(Message.ElapsedSyncDebounce({ version: 2 })), - Command.expectExact(SendPresenceUpdate({ status: "online", activeChannelId: general })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(state, { status: "online", activeChannelId: general })), + Command.resolve(SendPresenceUpdate, confirmed(state)), ) }) }) describe("later updates send only what changed", () => { test("switching channels sends the channel alone", () => { + const state = snapshot("online", random) story( update, given(synced()), message(Message.ChangedContext({ userId: ada, activeChannelId: random, nowMs: T0 })), message(Message.ElapsedSyncDebounce({ version: 2 })), - Command.expectExact(SendPresenceUpdate({ activeChannelId: random })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(state, { activeChannelId: random })), + Command.resolve(SendPresenceUpdate, confirmed(state)), ) }) test("15 minutes without activity flips to away; the next input flips back and is broadcast", () => { const afkAt = T0 + AFK_TIMEOUT_MS + const away = snapshot("away", general) + const online = snapshot("online", general) story( update, given(synced()), message(Message.ReachedAfkTimeout({ nowMs: afkAt })), message(Message.ElapsedSyncDebounce({ version: 2 })), - Command.expectExact(SendPresenceUpdate({ status: "away" })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(away, { status: "away" })), + Command.resolve(SendPresenceUpdate, confirmed(away)), message(Message.DetectedActivity({ atMs: afkAt + 5 })), Command.expectExact(BroadcastActivity({ atMs: afkAt + 5 })), Command.resolve(BroadcastActivity, Message.CompletedBroadcastActivity()), message(Message.ElapsedSyncDebounce({ version: 3 })), - Command.expectExact(SendPresenceUpdate({ status: "online" })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(online, { status: "online" })), + Command.resolve(SendPresenceUpdate, confirmed(online)), ) }) @@ -125,6 +146,7 @@ describe("later updates send only what changed", () => { }) test("activity from another tab counts, but never moves the clock backwards", () => { + const online = snapshot("online", general) story( update, given(synced({ isAfk: true, sent: { status: "away", activeChannelId: general } })), @@ -132,8 +154,58 @@ describe("later updates send only what changed", () => { Command.expectNone(), message(Message.ReceivedRemoteActivity({ atMs: T0 + AFK_TIMEOUT_MS + 1 })), message(Message.ElapsedSyncDebounce({ version: 2 })), - Command.expectExact(SendPresenceUpdate({ status: "online" })), - Command.resolve(SendPresenceUpdate, Message.SucceededSendPresenceUpdate()), + Command.expectExact(send(online, { status: "online" })), + Command.resolve(SendPresenceUpdate, confirmed(online)), + ) + }) +}) + +// P0: a failed write used to leave `sent` claiming the new status, so nothing ever re-sent it. +describe("a failed write", () => { + test("keeps the confirmed state and re-syncs after a backoff until the server has it", () => { + const busy = snapshot("busy", general) + story( + update, + given(synced()), + message(Message.ChangedContext({ userId: ada, activeChannelId: general, nowMs: T0 })), + model((m) => expect(m.syncVersion).toBe(1)), + ) + story( + update, + given(synced({ manualStatus: "busy", isSyncPending: true, syncVersion: 2 })), + message(Message.ElapsedSyncDebounce({ version: 2 })), + Command.expectExact(send(busy, { status: "busy" })), + Command.resolve(SendPresenceUpdate, Message.FailedSendPresenceUpdate({ userId: ada, reason: "Offline" })), + model((m) => { + expect(m.sent).toEqual(snapshot("online", general)) + expect(m.failedSyncs).toBe(1) + }), + Command.expectExact(WaitSyncRetry({ userId: ada, delayMs: syncRetryDelayMs(1) })), + Command.resolve(WaitSyncRetry, Message.ElapsedSyncRetry({ userId: ada })), + message(Message.ElapsedSyncDebounce({ version: 3 })), + Command.expectExact(send(busy, { status: "busy" })), + Command.resolve(SendPresenceUpdate, confirmed(busy)), + model((m) => { + expect(m.sent).toEqual(busy) + expect(m.failedSyncs).toBe(0) + }), + ) + }) + + test("backs off, doubling up to a cap", () => { + expect([1, 2, 3].map(syncRetryDelayMs)).toEqual([2_000, 4_000, 8_000]) + expect(syncRetryDelayMs(20)).toBe(60_000) + }) + + test("a result for a previous mount is ignored", () => { + const other = Schema.decodeSync(UserId)(uuid(9)) + story( + update, + given(synced()), + message(Message.SucceededSendPresenceUpdate({ userId: other, sent: snapshot("dnd", null) })), + model((m) => expect(m.sent).toEqual(snapshot("online", general))), + message(Message.FailedSendPresenceUpdate({ userId: other, reason: "Offline" })), + Command.expectNone(), ) }) }) @@ -145,7 +217,7 @@ describe("heartbeat", () => { given(synced()), message(Message.TickedHeartbeat()), Command.expectExact(SendHeartbeat()), - Command.resolve(SendHeartbeat, Message.FailedSendHeartbeat({ reason: "offline" })), + Command.resolve(SendHeartbeat, Message.FailedSendHeartbeat({ reason: "Offline" })), model((m) => expect(m.isHeartbeatInFlight).toBe(false)), ) story( @@ -158,10 +230,16 @@ describe("heartbeat", () => { }) describe("currentChannelIdAtom", () => { - test("reads the segment after `chat`, on any tab of the channel", () => { - expect(channelIdOfPathname(`/hazel/chat/${general}`)).toBe(general) - expect(channelIdOfPathname(`/hazel/chat/${general}/files/media`)).toBe(general) - expect(channelIdOfPathname("/hazel/chat")).toBeNull() - expect(channelIdOfPathname("/hazel/settings")).toBeNull() + const routeOf = (path: string) => + Option.match(fromString(`http://localhost${path}`), { + onNone: () => expect.unreachable(path), + onSome: urlToAppRoute, + }) + + test("is the route's channel, on any tab of the channel", () => { + expect(chatChannelIdOf(routeOf(`/hazel/chat/${general}`))).toBe(general) + expect(chatChannelIdOf(routeOf(`/hazel/chat/${general}/files/media`))).toBe(general) + expect(chatChannelIdOf(routeOf("/hazel/chat"))).toBeNull() + expect(chatChannelIdOf(routeOf("/hazel/settings"))).toBeNull() }) }) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts b/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts index c3cfdcf05..3e78134e5 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/sidebar.story.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import type { ChannelId, ChannelMemberId, ChannelSectionId } from "@hazel/schema" +import { ChannelId, ChannelMemberId, ChannelSectionId } from "@hazel/schema" import { Effect } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" @@ -17,12 +17,13 @@ import { } from "../channels-sidebar" import { UpdateChannelMember } from "./actions" import { PersistDismissedHint } from "./hints" +import { rowMenuEntries, sectionMenuEntries } from "./menus" /** The sidebar's menus, hint and action toasts: typed menu identity, outcomes and reasons. */ -const channelId = "00000000-0000-4000-8000-000000000001" as ChannelId -const memberId = "00000000-0000-4000-8000-000000000002" as ChannelMemberId -const sectionId = "00000000-0000-4000-8000-000000000003" as ChannelSectionId +const channelId = ChannelId.make("00000000-0000-4000-8000-000000000001") +const memberId = ChannelMemberId.make("00000000-0000-4000-8000-000000000002") +const sectionId = ChannelSectionId.make("00000000-0000-4000-8000-000000000003") const entry = { channel: { id: channelId, name: "general", type: "public", icon: null, sectionId: null }, @@ -42,6 +43,10 @@ const sectionMenu = (state: Model, menuMessage: Menu.Message) => update(state, Message.GotSectionMenuMessage({ sectionId, message: menuMessage })) const pressed = Menu.Message.PressedTrigger({ pointerType: "mouse" }) +const rowClicked = (key: string) => + Menu.Message.ClickedItem({ key, entries: rowMenuEntries(loaded.sections, false) }) +const sectionClicked = (key: string) => + Menu.Message.ClickedItem({ key, entries: sectionMenuEntries(["create-channel", "join-channel"], true) }) describe("open menu", () => { test("opening a row menu keeps it as a Row, keyed by channel", () => { @@ -64,7 +69,7 @@ describe("open menu", () => { test("selecting mute closes the menu and mutes the member", () => { const open = rowMenu(loaded, pressed).model - const { model: next, commands } = rowMenu(open, Menu.Message.ClickedItem({ key: "mute" })) + const { model: next, commands } = rowMenu(open, rowClicked("mute")) expect(next.openMenu).toBeNull() expect(commands?.find((command) => command.name === UpdateChannelMember.name)?.args).toEqual({ memberId, @@ -75,7 +80,7 @@ describe("open menu", () => { test("settings navigates to the channel's settings route", () => { const open = rowMenu(loaded, pressed).model - const { outMessage } = rowMenu(open, Menu.Message.ClickedItem({ key: "settings" })) + const { outMessage } = rowMenu(open, rowClicked("settings")) expect(outMessage).toEqual( PageOutMessage.RequestedNavigation({ route: AppRoute.ChannelSettings({ orgSlug: "acme", channelId }), @@ -86,12 +91,12 @@ describe("open menu", () => { test("a section-menu action asks for its modal", () => { const open = sectionMenu(loaded, pressed).model - const { outMessage } = sectionMenu(open, Menu.Message.ClickedItem({ key: "join-channel" })) + const { outMessage } = sectionMenu(open, sectionClicked("join-channel")) expect(outMessage).toEqual(PageOutMessage.RequestedModal({ modal: { _tag: "JoinChannel" } })) }) test("OpenMenu is a tagged union", () => { - const menu = Menu.init({ id: "x", entries: [] }) + const menu = Menu.init({ id: "x" }) expect(OpenMenu.Section({ sectionKey: "dms", menu })._tag).toBe("Section") }) }) diff --git a/apps/web-foldkit/src/shell/shell.story.test.ts b/apps/web-foldkit/src/shell/shell.story.test.ts index 12a439c10..0d1f036ac 100644 --- a/apps/web-foldkit/src/shell/shell.story.test.ts +++ b/apps/web-foldkit/src/shell/shell.story.test.ts @@ -1,12 +1,14 @@ // @vitest-environment jsdom import { OrganizationId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../page/out-message" +import { AppRoute } from "../route" import { ada, hazelOrg } from "../test/root-fixtures" import * as Menu from "../ui/menu" import * as Modal from "../ui/modal" +import { switcherLinkRoute, userMenuLinkRoute } from "./menus" import { Message, type Model } from "./model" import { type Context, informContext, init, update } from "./update" @@ -18,9 +20,10 @@ const context: Context = { canCreateChannel: true, organizationId: hazelOrg.id, currentUserId: ada.id, + isMobile: false, } const run = (model: Model, msg: Message) => update(model, msg, context) -const ready = informContext(init(), context).model +const ready = informContext(init({ isCreateChannelHintDismissed: false }), context) const userMenu = (inner: Menu.Message) => Message.GotUserMenuMessage({ message: inner }) const orgSwitcher = (inner: Menu.Message) => Message.GotOrgSwitcherMessage({ message: inner }) @@ -80,8 +83,10 @@ describe("org switcher", () => { given(withOrganizations), message(orgSwitcher(opened)), message(orgSwitcher(clicked("switch-server", switcherEntries))), - message(orgSwitcher(clicked(`org:${otherOrgId}`, switcherEntries))), - expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/acme", replace: false })), + message(orgSwitcher(clicked(otherOrgId, switcherEntries))), + expectOutMessage( + PageOutMessage.RequestedNavigation({ route: AppRoute.OrgHome({ orgSlug: "acme" }), replace: false }), + ), ) }) @@ -91,42 +96,44 @@ describe("org switcher", () => { given(withOrganizations), message(orgSwitcher(opened)), message(orgSwitcher(clicked("switch-server", switcherEntries))), - message(orgSwitcher(clicked(`org:${hazelOrg.id}`, switcherEntries))), + message(orgSwitcher(clicked(hazelOrg.id, switcherEntries))), expectNoOutMessage(), ) }) describe("on mobile (a flat list of organizations)", () => { - const mobile = run( - run(ready, Message.ChangedViewport({ isMobile: true })).model, - Message.UpdatedUserOrganizations({ organizations }), - ).model + const mobileContext: Context = { ...context, isMobile: true } + const runMobile = (model: Model, msg: Message) => update(model, msg, mobileContext) + const mobile = runMobile(informContext(ready, mobileContext), Message.UpdatedUserOrganizations({ organizations })) + .model test("checks the current organization", () => { expect(mobile.orgSwitcher.selectionMode).toBe("Single") - expect(mobile.orgSwitcher.selectedKeys).toEqual([`org:${hazelOrg.id}`]) + expect(mobile.orgSwitcher.selectedKeys).toEqual([hazelOrg.id]) }) // B2: the "Single" menu selects the clicked key before the shell folds its OutMessage. test("another organization navigates to its slug", () => { story( - run, + runMobile, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${otherOrgId}`, mobile.orgSwitcherEntries))), - expectOutMessage(PageOutMessage.RequestedNavigation({ href: "/acme", replace: false })), + message(orgSwitcher(clicked(otherOrgId, mobile.orgSwitcherEntries))), + expectOutMessage( + PageOutMessage.RequestedNavigation({ route: AppRoute.OrgHome({ orgSlug: "acme" }), replace: false }), + ), ) }) test("an organization without a slug goes back to setup", () => { story( - run, + runMobile, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${unfinishedOrgId}`, mobile.orgSwitcherEntries))), + message(orgSwitcher(clicked(unfinishedOrgId, mobile.orgSwitcherEntries))), expectOutMessage( PageOutMessage.RequestedNavigation({ - href: `/onboarding/setup-organization?orgId=${unfinishedOrgId}`, + route: AppRoute.OnboardingSetupOrganization({ orgId: Option.some(unfinishedOrgId) }), replace: false, }), ), @@ -135,10 +142,10 @@ describe("org switcher", () => { test("the current organization does nothing", () => { story( - run, + runMobile, given(mobile), message(orgSwitcher(opened)), - message(orgSwitcher(clicked(`org:${hazelOrg.id}`, mobile.orgSwitcherEntries))), + message(orgSwitcher(clicked(hazelOrg.id, mobile.orgSwitcherEntries))), expectNoOutMessage(), ) }) @@ -160,8 +167,7 @@ describe("mobile sidebar sheet", () => { describe("context", () => { test("switching organization rebuilds the menus with the new slug", () => { - const acme = informContext(ready, { ...context, orgSlug: "acme", organizationId: otherOrgId }).model - expect(acme.menuSignature).not.toBe(ready.menuSignature) + const acme = informContext(ready, { ...context, orgSlug: "acme", organizationId: otherOrgId }) const mySettings = acme.userMenuEntries.flatMap((entry) => entry._tag === "Item" && entry.item.key === "my-settings" && entry.item.href._tag === "Some" ? [entry.item.href.value] @@ -170,9 +176,13 @@ describe("context", () => { expect(mySettings).toEqual(["/acme/my-settings"]) }) - test("an unchanged context keeps the same menus", () => { - const again = informContext(ready, context).model - expect(again.userMenu).toBe(ready.userMenu) - expect(again.orgSwitcher).toBe(ready.orgSwitcher) + test("a link item's href and its keyboard navigation come from one route", () => { + expect(userMenuLinkRoute("my-settings", "hazel", ada.id)).toEqual( + Option.some(AppRoute.MySettingsAppearance({ orgSlug: "hazel" })), + ) + expect(switcherLinkRoute("server-settings", "hazel")).toEqual( + Option.some(AppRoute.SettingsGeneral({ orgSlug: "hazel" })), + ) + expect(userMenuLinkRoute("logout", "hazel", ada.id)).toEqual(Option.none()) }) }) From 59e2ca470c48e008756b9782bc6a111846e2bb44 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:56:18 +0200 Subject: [PATCH 40/52] refactor(web): chat reaction and pin actions report typed reasons (s6) ToggleReaction, PinMessage and UnpinMessage settle into Succeeded*/Failed*({ reason }) built with catchTags + catchCommon; write.ts maps reasons to copy. The unused DeleteMessage Command (the root's modal deletes) and action-effects.ts are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/page/chat/action-effects.ts | 21 ----- .../page/chat/channel/actions.story.test.ts | 39 +++++++- .../page/chat/channel/messages.scene.test.ts | 56 ++++++++++-- .../src/page/chat/channel/write.ts | 42 +++++++-- .../src/page/chat/message-actions.ts | 90 +++++++++---------- 5 files changed, 165 insertions(+), 83 deletions(-) delete mode 100644 apps/web-foldkit/src/page/chat/action-effects.ts diff --git a/apps/web-foldkit/src/page/chat/action-effects.ts b/apps/web-foldkit/src/page/chat/action-effects.ts deleted file mode 100644 index a6c48d1ac..000000000 --- a/apps/web-foldkit/src/page/chat/action-effects.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ErrorMessage } from "../../data/actions" - -/** The chat provider's `onErrorTag` messages, for `failureToast` and `toastOfExit` in `data/actions`. */ - -const retryAfterMsOf = (error: { readonly _tag: string }): number => - "retryAfterMs" in error && typeof error.retryAfterMs === "number" ? error.retryAfterMs : 0 - -/** `RateLimitExceededError` as the chat provider words it (`tryAgain` for edit and delete). */ -export const rateLimited = - (action: "sending another message" | "trying again") => - (error: { readonly _tag: string }): ErrorMessage => ({ - title: "Rate limit exceeded", - description: `Please wait ${Math.ceil(retryAfterMsOf(error) / 1000)} seconds before ${action}.`, - isRetryable: false, - }) - -export const notRetryable = (title: string, description: string): (() => ErrorMessage) => () => ({ - title, - description, - isRetryable: false, -}) diff --git a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts index bb12fc5c4..21bc3a136 100644 --- a/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/actions.story.test.ts @@ -52,7 +52,44 @@ describe("reactions", () => { Command.expectExact( ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), ), - Command.resolve(ToggleReaction, ActionMessage.CompletedToggleReaction({ toast: null })), + Command.resolve(ToggleReaction, ActionMessage.SucceededToggleReaction()), + ) + }) +}) + +describe("action results", () => { + const action = (inner: ActionMessage) => Message.GotActionMessage({ message: inner }) + + test("pin results toast; a failure names the reason, never server text", () => { + story( + updateWithShared, + given(loadedModel()), + message(action(ActionMessage.SucceededPinMessage())), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { intent: "success", title: "Message pinned", description: null }, + }), + ), + message(action(ActionMessage.FailedUnpinMessage({ reason: "PinNotFound" }))), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Pin not found", + description: "This message may have already been unpinned.", + }, + }), + ), + message(action(ActionMessage.FailedToggleReaction({ reason: "RateLimited" }))), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Rate limit exceeded", + description: "Please wait before trying again.", + }, + }), + ), ) }) }) diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 61790ccb5..e89bd6a61 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -86,7 +86,7 @@ describe("hover toolbar", () => { Scene.Command.expectExact( ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), ), - Scene.Command.resolve(ToggleReaction, ActionMessage.CompletedToggleReaction({ toast: null })), + Scene.Command.resolve(ToggleReaction, ActionMessage.SucceededToggleReaction()), ) }) @@ -180,17 +180,36 @@ describe("threads", () => { Scene.expect(Scene.text("Creating thread...")).toBeAbsent(), Scene.Mount.resolveAll( [ - MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread...", initialMarkdown: "" }), - threadDraft(Draft.Message.GotComposerMessage({ message: Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }) })), + MountEditor({ + editorId: threadEditorId, + placeholder: "Reply in thread...", + initialMarkdown: "", + }), + threadDraft( + Draft.Message.GotComposerMessage({ + message: Composer.Message.UpdatedDraft({ markdown: "", isEmpty: true }), + }), + ), ], [ { name: "TrackFileDrop" }, - threadDraft(Draft.Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ drag: DragState.Idle() }) })), + threadDraft( + Draft.Message.ReceivedDropEvent({ + event: DropEvent.ChangedDragState({ drag: DragState.Idle() }), + }), + ), ], ), Scene.click(Scene.role("button", { name: "Close thread" })), Scene.expect(Scene.role("heading", { name: "Thread" })).toBeAbsent(), - Scene.Mount.expectEnded(MountEditor({ editorId: threadEditorId, placeholder: "Reply in thread...", initialMarkdown: "" }), TrackFileDrop), + Scene.Mount.expectEnded( + MountEditor({ + editorId: threadEditorId, + placeholder: "Reply in thread...", + initialMarkdown: "", + }), + TrackFileDrop, + ), ) }) }) @@ -286,15 +305,34 @@ describe("composer emoji picker", () => { Scene.Command.expectExact(Picker.LoadEmojiData()), Scene.Command.resolve(Picker.LoadEmojiData, Picker.Message.SucceededLoadEmojiData({ data })), Scene.Mount.resolveAll( - [{ name: "PortalPickerPopover" }, emojiDialog(EmojiDialog.Message.ReceivedPopoverEvent({ event: PopoverEvent.CompletedPortalPickerPopover() }))], - [{ name: "MeasurePicker" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: measured }))], - [{ name: "TrackPickerFocusOut" }, emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: Picker.Message.BlurredPicker() }))], + [ + { name: "PortalPickerPopover" }, + emojiDialog( + EmojiDialog.Message.ReceivedPopoverEvent({ + event: PopoverEvent.CompletedPortalPickerPopover(), + }), + ), + ], + [ + { name: "MeasurePicker" }, + emojiDialog(EmojiDialog.Message.GotPickerMessage({ message: measured })), + ], + [ + { name: "TrackPickerFocusOut" }, + emojiDialog( + EmojiDialog.Message.GotPickerMessage({ message: Picker.Message.BlurredPicker() }), + ), + ], ), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toExist(), Scene.expect(Scene.role("gridcell", { name: "party popper" })).toExist(), // The popover around the dialog owns the Escape key (React Aria's Popover). Scene.keydown(Scene.selector(`#${popoverId(`${editorId}-emoji-picker`)}`), "Escape"), - Scene.Mount.expectEnded({ name: "PortalPickerPopover" }, { name: "MeasurePicker" }, { name: "TrackPickerFocusOut" }), + Scene.Mount.expectEnded( + { name: "PortalPickerPopover" }, + { name: "MeasurePicker" }, + { name: "TrackPickerFocusOut" }, + ), Scene.expect(Scene.role("dialog", { name: "Emoji picker" })).toBeAbsent(), Scene.Command.expectNone(), ) diff --git a/apps/web-foldkit/src/page/chat/channel/write.ts b/apps/web-foldkit/src/page/chat/channel/write.ts index 485f10e75..fa19a15c9 100644 --- a/apps/web-foldkit/src/page/chat/channel/write.ts +++ b/apps/web-foldkit/src/page/chat/channel/write.ts @@ -17,6 +17,7 @@ import { PageOutMessage } from "../../out-message" import { ActionMessage, type CopiedContent, + type MessageActionFailure, CopyText, CreateThread, DownloadImage, @@ -162,8 +163,13 @@ export const updateDraft = ( export const forwardComposerData = (model: Model, message: Composer.Message): PageReturn => updateDraft(model, "channel", Draft.Message.GotComposerMessage({ message }), null) -export const toComposerPresence = (presence: ReadonlyArray<{ readonly userId: UserId; readonly status: string }>) => - presence.map((row) => ({ userId: row.userId, status: isPresenceStatus(row.status) ? row.status : ("offline" as const) })) +export const toComposerPresence = ( + presence: ReadonlyArray<{ readonly userId: UserId; readonly status: string }>, +) => + presence.map((row) => ({ + userId: row.userId, + status: isPresenceStatus(row.status) ? row.status : ("offline" as const), + })) const isPresenceStatus = Schema.is(Composer.PresenceStatus) @@ -363,15 +369,39 @@ export const handleOverlaysOut = (model: Model, out: Overlays.OutMessage): PageR RequestedMessageAction: ({ messageId, action }) => messageAction(model, messageId, action), }) +/** A reaction or pin on a message that is gone. */ +const messageNotFoundToast = (reason: MessageActionFailure): ToastRequest => + errorToastOf( + failureMessage(reason, { + MessageNotFound: { + title: "Message not found", + description: "This message may have been deleted.", + }, + }), + ) + const withToast = (model: Model, request: ToastRequest | null): PageReturn => request === null ? { model } : { model, outMessage: toast(request) } export const handleActionMessage = (model: Model, message: ActionMessage): PageReturn => ActionMessage.match<PageReturn>(message, { - CompletedToggleReaction: ({ toast: request }) => withToast(model, request), - CompletedPinMessage: ({ toast: request }) => withToast(model, request), - CompletedUnpinMessage: ({ toast: request }) => withToast(model, request), - CompletedDeleteMessage: ({ toast: request }) => withToast(model, request), + SucceededToggleReaction: () => ({ model }), + FailedToggleReaction: ({ reason }) => withToast(model, messageNotFoundToast(reason)), + SucceededPinMessage: () => withToast(model, successToast("Message pinned")), + FailedPinMessage: ({ reason }) => withToast(model, messageNotFoundToast(reason)), + SucceededUnpinMessage: () => withToast(model, successToast("Message unpinned")), + FailedUnpinMessage: ({ reason }) => + withToast( + model, + errorToastOf( + failureMessage(reason, { + PinNotFound: { + title: "Pin not found", + description: "This message may have already been unpinned.", + }, + }), + ), + ), // The panel opens at once on the optimistic id; the composer waits for the RPC. CompletedGenerateThreadChannelId: ({ messageId, threadChannelId }) => { if (model.currentUserId === null || model.channel === null) return { model } diff --git a/apps/web-foldkit/src/page/chat/message-actions.ts b/apps/web-foldkit/src/page/chat/message-actions.ts index 7d12f519a..b57f89fb6 100644 --- a/apps/web-foldkit/src/page/chat/message-actions.ts +++ b/apps/web-foldkit/src/page/chat/message-actions.ts @@ -4,22 +4,19 @@ import { Command } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { createThreadAction, - deleteMessageAction, joinChannelAction, pinMessageAction, toggleReactionAction, unpinMessageAction, } from "~/db/actions" import { CommonReason } from "~/lib/error-messages" -import { ToastRequest } from "../../overlay/toasts" import { HazelRpc } from "../../rpc" -import { catchCommon, toastOfExit } from "../../data/actions" -import { notRetryable, rateLimited } from "./action-effects" +import { catchCommon } from "../../data/actions" /** * The message actions of `ChatProvider` and `useMessageActions`: each Command runs the same - * optimistic action (`db/actions.ts`) with the same variables. Reactions, pins and deletes still - * report a toast (`toastOfExit`); the thread, clipboard and join Commands report a typed reason. + * optimistic action (`db/actions.ts`) with the same variables and reports a typed reason on failure + * (`docs/foldkit-decisions/s6-errors.md`); `write.ts` turns it into copy. */ // MODEL @@ -59,13 +56,23 @@ export type CopiedContent = typeof CopiedContent.Type export const JoinChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) export type JoinChannelFailure = typeof JoinChannelFailure.Type +/** What can go wrong reacting to or pinning a message, beyond the shared reasons. */ +export const MessageActionFailure = Schema.Union([Schema.Literals(["MessageNotFound"]), CommonReason]) +export type MessageActionFailure = typeof MessageActionFailure.Type + +/** What can go wrong unpinning, beyond the shared reasons. */ +export const UnpinMessageFailure = Schema.Union([Schema.Literals(["PinNotFound"]), CommonReason]) +export type UnpinMessageFailure = typeof UnpinMessageFailure.Type + // MESSAGE export const ActionMessage = defineMessageUnion({ - CompletedToggleReaction: { toast: Schema.NullOr(ToastRequest) }, - CompletedPinMessage: { toast: Schema.NullOr(ToastRequest) }, - CompletedUnpinMessage: { toast: Schema.NullOr(ToastRequest) }, - CompletedDeleteMessage: { toast: Schema.NullOr(ToastRequest) }, + SucceededToggleReaction: {}, + FailedToggleReaction: { reason: MessageActionFailure }, + SucceededPinMessage: {}, + FailedPinMessage: { reason: MessageActionFailure }, + SucceededUnpinMessage: {}, + FailedUnpinMessage: { reason: UnpinMessageFailure }, CompletedGenerateThreadChannelId: { messageId: MessageId, threadChannelId: ChannelId }, SucceededCreateThread: { threadChannelId: ChannelId }, FailedCreateThread: { threadChannelId: ChannelId, reason: CreateThreadFailure }, @@ -82,58 +89,49 @@ export type ActionMessage = typeof ActionMessage.Type /** `ActionMessage` under the Submodel name, so a parent's Got wrapper imports it as `Message`. */ export { ActionMessage as Message } -const messageNotFound = notRetryable("Message not found", "This message may have been deleted.") - // COMMAND /** `addReaction`: `toggleReactionAction` inserts or deletes the reaction, then `messageReaction.toggle`. */ export const ToggleReaction = Command.define("ToggleReaction", { args: { messageId: MessageId, channelId: ChannelId, emoji: Schema.String, userId: UserId }, - messages: [ActionMessage.CompletedToggleReaction], + messages: [ActionMessage.SucceededToggleReaction, ActionMessage.FailedToggleReaction], execute: (args) => - toastOfExit(toggleReactionAction(args), "exitToast", { - handlers: { MessageNotFoundError: messageNotFound }, - }).pipe(Effect.map((toast) => ActionMessage.CompletedToggleReaction({ toast }))), + toggleReactionAction(args).pipe( + Effect.as(ActionMessage.SucceededToggleReaction()), + Effect.catchTags({ + MessageNotFoundError: () => + Effect.succeed(ActionMessage.FailedToggleReaction({ reason: "MessageNotFound" })), + }), + catchCommon((reason) => ActionMessage.FailedToggleReaction({ reason })), + ), }) export const PinMessage = Command.define("PinMessage", { args: { messageId: MessageId, channelId: ChannelId, userId: UserId }, - messages: [ActionMessage.CompletedPinMessage], + messages: [ActionMessage.SucceededPinMessage, ActionMessage.FailedPinMessage], execute: (args) => - toastOfExit(pinMessageAction(args), "exitToast", { - success: "Message pinned", - handlers: { MessageNotFoundError: messageNotFound }, - }).pipe(Effect.map((toast) => ActionMessage.CompletedPinMessage({ toast }))), + pinMessageAction(args).pipe( + Effect.as(ActionMessage.SucceededPinMessage()), + Effect.catchTags({ + MessageNotFoundError: () => + Effect.succeed(ActionMessage.FailedPinMessage({ reason: "MessageNotFound" })), + }), + catchCommon((reason) => ActionMessage.FailedPinMessage({ reason })), + ), }) export const UnpinMessage = Command.define("UnpinMessage", { args: { pinnedMessageId: PinnedMessageId }, - messages: [ActionMessage.CompletedUnpinMessage], - execute: (args) => - toastOfExit(unpinMessageAction(args), "exitToast", { - success: "Message unpinned", - handlers: { - PinnedMessageNotFoundError: notRetryable( - "Pin not found", - "This message may have already been unpinned.", - ), - }, - }).pipe(Effect.map((toast) => ActionMessage.CompletedUnpinMessage({ toast }))), -}) - -export const DeleteMessage = Command.define("DeleteMessage", { - args: { messageId: MessageId }, - messages: [ActionMessage.CompletedDeleteMessage], + messages: [ActionMessage.SucceededUnpinMessage, ActionMessage.FailedUnpinMessage], execute: (args) => - toastOfExit(deleteMessageAction(args), "exitToast", { - handlers: { - RateLimitExceededError: rateLimited("trying again"), - MessageNotFoundError: notRetryable( - "Message not found", - "This message may have already been deleted.", - ), - }, - }).pipe(Effect.map((toast) => ActionMessage.CompletedDeleteMessage({ toast }))), + unpinMessageAction(args).pipe( + Effect.as(ActionMessage.SucceededUnpinMessage()), + Effect.catchTags({ + PinnedMessageNotFoundError: () => + Effect.succeed(ActionMessage.FailedUnpinMessage({ reason: "PinNotFound" })), + }), + catchCommon((reason) => ActionMessage.FailedUnpinMessage({ reason })), + ), }) /** `createThread` generates the thread id up front so the panel opens before the RPC answers. */ From 71dbdfef980288b64f70946b25a84d4d435c5e0f Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:57:37 +0200 Subject: [PATCH 41/52] refactor(web): shared ConfirmAction and DropZone Submodels, typed upload problems, name rules Drop the two local embedInteraction copies for Interaction.embed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/overlay/modal/install-bot-by-id.ts | 7 +- .../src/page/channel-settings/name-rules.ts | 17 ++ .../src/page/my-settings/shared.ts | 21 --- .../integrations/shared/interaction.ts | 21 --- apps/web-foldkit/src/page/settings/upload.ts | 161 +++++++++++++----- .../src/page/shared/confirm-action.ts | 95 +++++++++++ apps/web-foldkit/src/page/shared/drop-zone.ts | 104 +++++++++++ .../src/page/shared/shared.test.ts | 60 +++++++ 8 files changed, 398 insertions(+), 88 deletions(-) create mode 100644 apps/web-foldkit/src/page/channel-settings/name-rules.ts delete mode 100644 apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts create mode 100644 apps/web-foldkit/src/page/shared/confirm-action.ts create mode 100644 apps/web-foldkit/src/page/shared/drop-zone.ts create mode 100644 apps/web-foldkit/src/page/shared/shared.test.ts diff --git a/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts b/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts index 1cecc7663..9627db428 100644 --- a/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts +++ b/apps/web-foldkit/src/overlay/modal/install-bot-by-id.ts @@ -3,7 +3,7 @@ import { Cause, Effect, Option, Schema } from "effect" import { Command, Submodel } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" -import { embedInteraction } from "../../page/settings/integrations/shared/interaction" +import type { PageSubscriptionInput } from "../../page/contract" import { HazelRpc } from "../../rpc" import * as Interaction from "../../ui/aria/interaction" import { button } from "../../ui/button" @@ -87,7 +87,10 @@ export const InstallBotById = Command.define("InstallBotById", { type Return = ModalReturn<Model, Message> -const interaction = embedInteraction<Model, Message>((message) => Message.GotInteractionMessage({ message })) +const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, +) const INSTALL_TARGET = "install" diff --git a/apps/web-foldkit/src/page/channel-settings/name-rules.ts b/apps/web-foldkit/src/page/channel-settings/name-rules.ts new file mode 100644 index 000000000..9b499242f --- /dev/null +++ b/apps/web-foldkit/src/page/channel-settings/name-rules.ts @@ -0,0 +1,17 @@ +import { makeRules, Rule, type Rules } from "foldkit/fieldValidation" + +/** arktype `name: "1<string<101"` (exclusive bounds): 2 to 100 characters, with copy naming the field. */ +const nameRules = (label: string): Rules<string> => + makeRules({ + required: `${label} is required`, + rules: [ + Rule.minLength(2, `${label} must be at least 2 characters`), + Rule.maxLength(100, `${label} must be at most 100 characters`), + ], + }) + +/** The channel settings form's name. */ +export const channelNameRules = nameRules("Channel name") + +/** The create-webhook form's name (the same bounds as a channel's). */ +export const webhookNameRules = nameRules("Webhook name") diff --git a/apps/web-foldkit/src/page/my-settings/shared.ts b/apps/web-foldkit/src/page/my-settings/shared.ts index 946bd35cc..2417981ae 100644 --- a/apps/web-foldkit/src/page/my-settings/shared.ts +++ b/apps/web-foldkit/src/page/my-settings/shared.ts @@ -1,7 +1,4 @@ -import { Option } from "effect" -import { Subscription, Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import * as Interaction from "../../ui/aria/interaction" import { sectionHeaderGroup, sectionHeaderHeading, @@ -9,7 +6,6 @@ import { sectionHeaderSubheading, } from "../../ui/section-header" import { sectionLabelRoot } from "../../ui/section-label" -import type { PageSubscriptionInput } from "../contract" /** Markup every my-settings page repeats, ported from the legacy route components. */ @@ -38,20 +34,3 @@ export const settingsRow = <Message>( /** `<hr className="h-px w-full border-none bg-border" />` */ export const divider = <Message>(h: HtmlBuilder<Message>): Html => h.hr([h.Class("h-px w-full border-none bg-border")]) - -/** The interaction Submodel wiring (hover, press, focus-visible) a page embeds once. */ -export const embedInteraction = <Model extends { readonly interaction: Interaction.Model }, Message>( - toParentMessage: (message: Interaction.Message) => Message, -) => ({ - fold: Update.foldChild({ - update: Interaction.update, - read: (model: Model) => Option.some(model.interaction), - write: (model: Model, interaction: Interaction.Model): Model => ({ ...model, interaction }), - toParentMessage, - }), - subscriptions: Subscription.lift(Interaction.subscriptions)<PageSubscriptionInput<Model>, Message>({ - read: (input) => Option.some(input.model.interaction), - toParentMessage, - }), - wiring: (model: Model): Interaction.Wiring<Message> => ({ model: model.interaction, toParentMessage }), -}) diff --git a/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts b/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts deleted file mode 100644 index 16939d813..000000000 --- a/apps/web-foldkit/src/page/settings/integrations/shared/interaction.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Option } from "effect" -import { Subscription, Update } from "foldkit" -import * as Interaction from "../../../../ui/aria/interaction" -import type { PageSubscriptionInput } from "../../../contract" - -/** A page's interaction Submodel (React Aria hover, press, focus state): fold, Subscriptions, wiring. */ -export const embedInteraction = <Model extends { readonly interaction: Interaction.Model }, Message>( - toParentMessage: (message: Interaction.Message) => Message, -) => ({ - fold: Update.foldChild({ - update: Interaction.update, - read: (model: Model) => Option.some(model.interaction), - write: (model: Model, interaction: Interaction.Model): Model => ({ ...model, interaction }), - toParentMessage, - }), - subscriptions: Subscription.lift(Interaction.subscriptions)<PageSubscriptionInput<Model>, Message>({ - read: (input) => Option.some(input.model.interaction), - toParentMessage, - }), - wiring: (model: Model): Interaction.Wiring<Message> => ({ model: model.interaction, toParentMessage }), -}) diff --git a/apps/web-foldkit/src/page/settings/upload.ts b/apps/web-foldkit/src/page/settings/upload.ts index 07d699fe0..f51fe2746 100644 --- a/apps/web-foldkit/src/page/settings/upload.ts +++ b/apps/web-foldkit/src/page/settings/upload.ts @@ -1,70 +1,143 @@ -import type { OrganizationId } from "@hazel/schema" +import { OrganizationId } from "@hazel/schema" import { Effect, Schema } from "effect" -import { uploadErrorMessages, uploadToStorage } from "~/lib/upload-to-storage" +import { defineTaggedUnion } from "foldkit/schema" +import { type UploadErrorType, uploadErrorMessages, uploadToStorage } from "~/lib/upload-to-storage" +import type { CommonError, UserErrorMessage } from "~/lib/error-messages" import { HazelApiClient } from "../../rpc" /** Port of `hooks/use-upload.ts` for the settings uploads (avatars, organization logo, custom emoji). */ export const MAX_AVATAR_SIZE = 5 * 1024 * 1024 export const MAX_EMOJI_SIZE = 256 * 1024 -export const ALLOWED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"] -export const ALLOWED_EMOJI_TYPES = ["image/png", "image/gif", "image/webp"] +export const ALLOWED_AVATAR_TYPES: ReadonlyArray<string> = ["image/jpeg", "image/png", "image/webp"] +export const ALLOWED_EMOJI_TYPES: ReadonlyArray<string> = ["image/png", "image/gif", "image/webp"] + +/** What the file is for; the presign request's `type` and its organization. */ +export const UploadTarget = defineTaggedUnion({ + UserAvatar: {}, + OrganizationAvatar: { organizationId: OrganizationId }, + CustomEmoji: { organizationId: OrganizationId }, +}) +export type UploadTarget = typeof UploadTarget.Type + +/** Avatars and logos share one set of limits; emoji have their own. */ +export type UploadKind = "avatar" | "emoji" + +const kindOf = (target: UploadTarget): UploadKind => (target._tag === "CustomEmoji" ? "emoji" : "avatar") + +/** Why an upload failed before or while storing the file (the backend's own failures are `CommonReason`s). */ +export const UploadProblem = Schema.Literals([ + "InvalidType", + "TooLarge", + /** `VITE_R2_PUBLIC_URL` is unset, so no public URL can be built. */ + "NotConfigured", + /** The presign endpoint refused the file (`UploadError`) or its owner is gone. */ + "PresignRejected", + "StorageOffline", + "StorageTimeout", + "StorageError", + "StorageCancelled", +]) +export type UploadProblem = typeof UploadProblem.Type export class UploadFailedError extends Schema.TaggedError<UploadFailedError>()("UploadFailedError", { - message: Schema.String, - description: Schema.NullOr(Schema.String), + problem: UploadProblem, }) {} -export type UploadTarget = - | { readonly type: "user-avatar" } - | { readonly type: "organization-avatar"; readonly organizationId: OrganizationId } - | { readonly type: "custom-emoji"; readonly organizationId: OrganizationId } +/** The file check `uploadFile` runs first, for views and updates that reject a file before uploading it. */ +export const validateUploadFile = (kind: UploadKind, file: File): "InvalidType" | "TooLarge" | null => { + const allowed = kind === "emoji" ? ALLOWED_EMOJI_TYPES : ALLOWED_AVATAR_TYPES + if (!allowed.includes(file.type)) return "InvalidType" + if (file.size > (kind === "emoji" ? MAX_EMOJI_SIZE : MAX_AVATAR_SIZE)) return "TooLarge" + return null +} + +const storageProblem: { readonly [T in UploadErrorType]: UploadProblem } = { + network: "StorageOffline", + timeout: "StorageTimeout", + server: "StorageError", + aborted: "StorageCancelled", +} -const fail = (message: string, description: string | null) => - Effect.fail(new UploadFailedError({ message, description })) +const presignPayload = (target: UploadTarget) => + UploadTarget.match(target, { + UserAvatar: () => ({ type: "user-avatar" as const }), + OrganizationAvatar: ({ organizationId }) => ({ + type: "organization-avatar" as const, + organizationId, + }), + CustomEmoji: ({ organizationId }) => ({ + type: "custom-emoji" as const, + organizationId, + }), + }) -/** Validates, presigns and PUTs the file; succeeds with the storage key (the legacy `UploadResult`). */ +const fail = (problem: UploadProblem) => Effect.fail(new UploadFailedError({ problem })) + +/** + * Validates, presigns and PUTs the file; succeeds with its public URL. The presign call's transport, auth + * and rate-limit failures stay `CommonError`s for the Command's `catchCommon`. + */ export const uploadFile = ( target: UploadTarget, file: File, -): Effect.Effect<string, UploadFailedError, HazelApiClient> => +): Effect.Effect<string, UploadFailedError | CommonError, HazelApiClient> => Effect.gen(function* () { - const isEmoji = target.type === "custom-emoji" - const allowed = isEmoji ? ALLOWED_EMOJI_TYPES : ALLOWED_AVATAR_TYPES - if (!allowed.includes(file.type)) { - return yield* fail( - "Invalid file type", - isEmoji - ? "Please select a PNG, GIF, or WebP image" - : "Please select a JPEG, PNG, or WebP image", - ) - } - const maxSize = isEmoji ? MAX_EMOJI_SIZE : MAX_AVATAR_SIZE - if (file.size > maxSize) { - return yield* fail( - "File too large", - `File size must be less than ${isEmoji ? "256KB" : `${maxSize / 1024 / 1024}MB`}`, - ) - } + const invalid = validateUploadFile(kindOf(target), file) + if (invalid !== null) return yield* fail(invalid) + const base = import.meta.env.VITE_R2_PUBLIC_URL + if (!base) return yield* fail("NotConfigured") const presigned = yield* HazelApiClient.use((client) => client.uploads.presign({ - payload: { ...target, contentType: file.type, fileSize: file.size }, + payload: { ...presignPayload(target), contentType: file.type, fileSize: file.size }, + }), + ).pipe( + Effect.catchTags({ + UploadError: () => fail("PresignRejected"), + BotNotFoundForUploadError: () => fail("PresignRejected"), + OrganizationNotFoundForUploadError: () => fail("PresignRejected"), }), - ).pipe(Effect.catch(() => fail("Upload failed", "Failed to get upload URL. Please try again."))) - // A rejection is a failed upload with the generic message, not a defect that leaves the spinner on. + ) + // `uploadToStorage` settles every XHR outcome; a throw is a storage error, not a defect. const result = yield* Effect.tryPromise({ try: () => uploadToStorage(presigned.uploadUrl, file, { timeout: 60000 }), - catch: () => - new UploadFailedError({ message: "Upload failed", description: uploadErrorMessages.server }), + catch: () => new UploadFailedError({ problem: "StorageError" }), }) - if (!result.success) { - return yield* fail("Upload failed", uploadErrorMessages[result.errorType ?? "server"]) - } - return presigned.key + if (!result.success) return yield* fail(storageProblem[result.errorType ?? "server"]) + // The frontend builds the public URL (the backend may not know its public bucket URL). + return `${base}/${presigned.key}` }) -/** The public URL the frontend builds from the key (the backend may not know its public bucket URL). */ -export const publicUrlOf = (key: string): string | null => { - const base = import.meta.env.VITE_R2_PUBLIC_URL - return base ? `${base}/${key}` : null +/** The copy for an upload problem; the file checks name the kind's types and size limit. */ +export const uploadProblemMessage = (kind: UploadKind, problem: UploadProblem): UserErrorMessage => { + switch (problem) { + case "InvalidType": + return { + title: "Invalid file type", + description: + kind === "emoji" + ? "Please select a PNG, GIF, or WebP image" + : "Please select a JPEG, PNG, or WebP image", + } + case "TooLarge": + return { + title: "File too large", + description: `File size must be less than ${kind === "emoji" ? "256KB" : "5MB"}`, + } + case "NotConfigured": + return { + title: "Configuration error", + description: "Image upload is not configured. Please contact support.", + } + case "PresignRejected": + return { title: "Upload failed", description: "Failed to get upload URL. Please try again." } + case "StorageOffline": + return { title: "Upload failed", description: uploadErrorMessages.network } + case "StorageTimeout": + return { title: "Upload failed", description: uploadErrorMessages.timeout } + case "StorageError": + return { title: "Upload failed", description: uploadErrorMessages.server } + case "StorageCancelled": + return { title: "Upload failed", description: uploadErrorMessages.aborted } + } } diff --git a/apps/web-foldkit/src/page/shared/confirm-action.ts b/apps/web-foldkit/src/page/shared/confirm-action.ts new file mode 100644 index 000000000..9b86d7f6c --- /dev/null +++ b/apps/web-foldkit/src/page/shared/confirm-action.ts @@ -0,0 +1,95 @@ +import { Option, Schema } from "effect" +import type { Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { taggedStruct } from "foldkit/schema" +import * as Modal from "../../ui/modal" + +/** + * A destructive action behind a confirmation dialog: nothing asked, asking about a target, or running the + * action for it. The dialog is part of the Submodel, so it can't be open without a target. While `Running` + * the dialog may be dismissed; the result still settles the state. + * + * Parents call `ask` / `confirm` / `succeeded` / `failed` from their own Messages, fold `Message` with + * `update`, and render the dialog with `model.modal` (`toModalMessage` wraps its Messages). + */ + +// MODEL + +export type State<Target> = + | { readonly _tag: "Closed" } + | { readonly _tag: "Confirming"; readonly target: Target } + | { readonly _tag: "Running"; readonly target: Target } + +export interface Model<Target> { + readonly state: State<Target> + readonly modal: Modal.Model +} + +/** The Model's Schema for a target Schema. */ +export const Model = <Target, Encoded>(target: Schema.Codec<Target, Encoded>) => + Schema.Struct({ + state: Schema.Union([ + taggedStruct("Closed"), + taggedStruct("Confirming", { target }), + taggedStruct("Running", { target }), + ]), + modal: Modal.Model, + }) + +export const init = <Target>(id: string): Model<Target> => ({ state: { _tag: "Closed" }, modal: Modal.init(id) }) + +// MESSAGE + +export const Message = defineMessageUnion({ + GotModalMessage: { message: Modal.Message }, +}) +export type Message = typeof Message.Type + +export const toModalMessage = (message: Modal.Message): Message => Message.GotModalMessage({ message }) + +// QUERIES + +/** The target being asked about or acted on. */ +export const targetOf = <Target>(model: Model<Target>): Option.Option<Target> => + model.state._tag === "Closed" ? Option.none() : Option.some(model.state.target) + +export const isRunning = <Target>(model: Model<Target>): boolean => model.state._tag === "Running" + +// TRANSITIONS + +/** Opens the dialog for `target`; ignored while an action runs. */ +export const ask = <Target>(model: Model<Target>, target: Target): Model<Target> => + isRunning(model) ? model : { state: { _tag: "Confirming", target }, modal: Modal.opened(model.modal) } + +/** The user confirmed: `Running`, with the target to act on. `None` unless a confirmation is open. */ +export const confirm = <Target>(model: Model<Target>): Option.Option<readonly [Model<Target>, Target]> => + model.state._tag === "Confirming" + ? Option.some([{ ...model, state: { _tag: "Running", target: model.state.target } }, model.state.target]) + : Option.none() + +/** The action succeeded (or the parent dismisses it): closed. */ +export const close = <Target>(model: Model<Target>): Model<Target> => ({ + state: { _tag: "Closed" }, + modal: Modal.closed(model.modal), +}) + +/** The action failed: back to asking if the dialog is still open, so the user can retry or cancel. */ +export const failed = <Target>(model: Model<Target>): Model<Target> => + model.state._tag === "Running" && model.modal.isOpen + ? { ...model, state: { _tag: "Confirming", target: model.state.target } } + : close(model) + +// UPDATE + +export const update = <Target>( + model: Model<Target>, + message: Message, +): Update.Return<Model<Target>, Message> => + Message.match<Update.Return<Model<Target>, Message>>(message, { + GotModalMessage: ({ message: modalMessage }) => { + const modal = Modal.update(model.modal, modalMessage).model + // Dismissing the dialog cancels a pending confirmation; a running action settles on its own. + const state = !modal.isOpen && model.state._tag === "Confirming" ? { _tag: "Closed" as const } : model.state + return { model: { state, modal } } + }, + }) diff --git a/apps/web-foldkit/src/page/shared/drop-zone.ts b/apps/web-foldkit/src/page/shared/drop-zone.ts new file mode 100644 index 000000000..16a4beb40 --- /dev/null +++ b/apps/web-foldkit/src/page/shared/drop-zone.ts @@ -0,0 +1,104 @@ +import { Option, Schema, Stream } from "effect" +import { Dom, type Update } from "foldkit" +import type { File } from "foldkit/file" +import type { Attribute, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" + +/** + * A file drop target (React Aria `DropZone`): whether a drag is over the zone, and optionally how many file + * drags are over the page (legacy `useDragDetection`, for a "Drop image" hint). The dropped files go to the + * parent's own Message; call `reset` when it handles them. + */ + +// MODEL + +export const Model = Schema.Struct({ + isOver: Schema.Boolean, + /** Nested `dragenter`/`dragleave` depth of file drags anywhere on the page. */ + pageDragDepth: Schema.Number, +}) +export type Model = typeof Model.Type + +export const init = (): Model => ({ isOver: false, pageDragDepth: 0 }) + +/** After a drop (or a cancelled drag): nothing hovers. */ +export const reset = (): Model => init() + +export const isDraggingOnPage = (model: Model): boolean => model.pageDragDepth > 0 + +// MESSAGE + +export const Message = defineMessageUnion({ + EnteredZone: {}, + LeftZone: {}, + DraggedFilesOverPage: { isEntering: Schema.Boolean }, + EndedPageDrag: {}, +}) +export type Message = typeof Message.Type + +// UPDATE + +export const update = (model: Model, message: Message): Update.Return<Model, Message> => + Message.match<Update.Return<Model, Message>>(message, { + EnteredZone: () => ({ model: { ...model, isOver: true } }), + LeftZone: () => ({ model: { ...model, isOver: false } }), + DraggedFilesOverPage: ({ isEntering }) => ({ + model: { ...model, pageDragDepth: Math.max(0, model.pageDragDepth + (isEntering ? 1 : -1)) }, + }), + EndedPageDrag: () => ({ model: reset() }), + }) + +// VIEW + +/** The zone's drag handlers and `data-drop-target`; `onDrop` is the parent's file Message. */ +export const zoneAttributes = <ParentMessage>( + h: HtmlBuilder<ParentMessage>, + model: Model, + toParentMessage: (message: Message) => ParentMessage, + onDrop: (files: ReadonlyArray<File>) => ParentMessage, +): ReadonlyArray<Attribute<ParentMessage>> => [ + ...(model.isOver ? [h.DataAttribute("drop-target", "true")] : []), + h.AllowDrop(), + h.OnDragEnter(toParentMessage(Message.EnteredZone())), + h.OnDragLeave(toParentMessage(Message.LeftZone())), + h.OnDragOver(toParentMessage(Message.EnteredZone())), + h.OnDropFiles((files) => onDrop(files)), +] + +// SUBSCRIPTIONS + +const hasFiles = (event: DragEvent) => event.dataTransfer?.types.includes("Files") ?? false + +/** + * `useDragDetection`: file drags anywhere on the page, for zones that hint before the drag reaches them. + * The parent runs it as a `Subscription.persistentEntry`. + */ +export const pageDragStream = <ParentMessage>(toParentMessage: (message: Message) => ParentMessage) => + Stream.mergeAll( + [ + Dom.streamFromEventFilterMap({ + target: document, + type: "dragenter", + filterMapEvent: (event) => + hasFiles(event) + ? Option.some(toParentMessage(Message.DraggedFilesOverPage({ isEntering: true }))) + : Option.none(), + }), + Dom.streamFromEvent({ + target: document, + type: "dragleave", + mapEvent: () => toParentMessage(Message.DraggedFilesOverPage({ isEntering: false })), + }), + Dom.streamFromEvent({ + target: document, + type: "drop", + mapEvent: () => toParentMessage(Message.EndedPageDrag()), + }), + Dom.streamFromEvent({ + target: document, + type: "dragend", + mapEvent: () => toParentMessage(Message.EndedPageDrag()), + }), + ], + { concurrency: "unbounded" }, + ) diff --git a/apps/web-foldkit/src/page/shared/shared.test.ts b/apps/web-foldkit/src/page/shared/shared.test.ts new file mode 100644 index 000000000..5fe1c8773 --- /dev/null +++ b/apps/web-foldkit/src/page/shared/shared.test.ts @@ -0,0 +1,60 @@ +import { Option } from "effect" +import { describe, expect, it } from "vitest" +import * as Modal from "../../ui/modal" +import { validateUploadFile } from "../settings/upload" +import * as ConfirmAction from "./confirm-action" +import * as DropZone from "./drop-zone" + +const fileOf = (type: string, size: number) => new File([new Uint8Array(size)], "file", { type }) + +describe("ConfirmAction", () => { + const asked = ConfirmAction.ask(ConfirmAction.init<string>("delete"), "row-1") + + it("opens the dialog with its target", () => { + expect(asked.state).toEqual({ _tag: "Confirming", target: "row-1" }) + expect(asked.modal.isOpen).toBe(true) + }) + + it("runs the confirmed target and closes on success", () => { + const [running, target] = Option.getOrThrow(ConfirmAction.confirm(asked)) + expect(target).toBe("row-1") + expect(ConfirmAction.isRunning(running)).toBe(true) + expect(ConfirmAction.confirm(running)).toEqual(Option.none()) + const closed = ConfirmAction.close(running) + expect(closed.state._tag).toBe("Closed") + expect(closed.modal.isOpen).toBe(false) + }) + + it("returns to asking when the action fails with the dialog open", () => { + const [running] = Option.getOrThrow(ConfirmAction.confirm(asked)) + expect(ConfirmAction.failed(running).state).toEqual({ _tag: "Confirming", target: "row-1" }) + }) + + it("cancels a pending confirmation when the dialog is dismissed, but not a running action", () => { + const dismiss = ConfirmAction.toModalMessage(Modal.Message.PressedEscape()) + expect(ConfirmAction.update(asked, dismiss).model.state._tag).toBe("Closed") + const [running] = Option.getOrThrow(ConfirmAction.confirm(asked)) + const dismissed = ConfirmAction.update(running, dismiss).model + expect(dismissed.state._tag).toBe("Running") + expect(ConfirmAction.failed(dismissed).state._tag).toBe("Closed") + }) +}) + +describe("DropZone", () => { + it("tracks the zone hover and nested page drags", () => { + const over = DropZone.update(DropZone.init(), DropZone.Message.EnteredZone()).model + expect(over.isOver).toBe(true) + const entered = DropZone.update(over, DropZone.Message.DraggedFilesOverPage({ isEntering: true })).model + expect(DropZone.isDraggingOnPage(entered)).toBe(true) + expect(DropZone.update(entered, DropZone.Message.EndedPageDrag()).model).toEqual(DropZone.init()) + }) +}) + +describe("validateUploadFile", () => { + it("checks the kind's types and size limit", () => { + expect(validateUploadFile("avatar", fileOf("image/png", 10))).toBeNull() + expect(validateUploadFile("avatar", fileOf("image/gif", 10))).toBe("InvalidType") + expect(validateUploadFile("emoji", fileOf("image/gif", 10))).toBeNull() + expect(validateUploadFile("emoji", fileOf("image/png", 300 * 1024))).toBe("TooLarge") + }) +}) From dcb99a27a2b8af5e85b9d2b365c9c6d22a36c178 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:57:37 +0200 Subject: [PATCH 42/52] fix(web): settings pages show load failures, typed failure reasons, AsyncData and field validation Invitations, connect invites and chat-sync lists render a Failure branch with retry instead of empty/not-found; custom emoji and logo uploads keep their specific errors; delete confirmations use ConfirmAction; integration detail validates token and base URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../chat-sync-connection/add-link-view.ts | 86 ++-- .../settings/chat-sync-connection/command.ts | 157 +++---- .../settings/chat-sync-connection/model.ts | 96 ++-- .../chat-sync-connection/scene.test.ts | 115 +++-- .../chat-sync-connection/story.test.ts | 200 ++++++-- .../chat-sync-connection/subscription.ts | 11 +- .../settings/chat-sync-connection/update.ts | 341 +++++++++----- .../settings/chat-sync-connection/view.ts | 144 ++++-- .../settings/chat-sync/add-connection-view.ts | 115 +++-- .../page/settings/chat-sync/add-connection.ts | 47 +- .../page/settings/chat-sync/confirm-dialog.ts | 29 +- .../src/page/settings/chat-sync/discord.ts | 36 +- .../src/page/settings/chat-sync/model.ts | 62 ++- .../src/page/settings/chat-sync/scene.test.ts | 103 +++-- .../src/page/settings/chat-sync/story.test.ts | 177 ++++--- .../src/page/settings/chat-sync/update.ts | 278 ++++++----- .../src/page/settings/chat-sync/view.ts | 64 ++- .../page/settings/connect-invites/message.ts | 11 +- .../page/settings/connect-invites/model.ts | 19 +- .../settings/connect-invites/scene.test.ts | 49 +- .../settings/connect-invites/story.test.ts | 75 ++- .../settings/connect-invites/subscription.ts | 4 +- .../page/settings/connect-invites/update.ts | 180 ++++---- .../src/page/settings/connect-invites/view.ts | 151 ++++-- .../page/settings/custom-emojis/message.ts | 19 +- .../src/page/settings/custom-emojis/modals.ts | 54 ++- .../src/page/settings/custom-emojis/model.ts | 33 +- .../page/settings/custom-emojis/scene.test.ts | 5 +- .../page/settings/custom-emojis/story.test.ts | 156 +++++-- .../src/page/settings/custom-emojis/update.ts | 431 ++++++++++-------- .../src/page/settings/custom-emojis/view.ts | 99 ++-- .../page/settings/general/delete-workspace.ts | 36 +- .../settings/general/general.scene.test.ts | 18 +- .../src/page/settings/general/message.ts | 25 +- .../src/page/settings/general/model.ts | 54 ++- .../src/page/settings/general/story.test.ts | 109 +++-- .../src/page/settings/general/update.ts | 389 +++++++++------- .../src/page/settings/general/view.ts | 65 ++- .../settings/integrations/detail/command.ts | 87 ++-- .../settings/integrations/detail/message.ts | 26 +- .../settings/integrations/detail/model.ts | 35 +- .../integrations/detail/scene.test.ts | 47 +- .../integrations/detail/story.test.ts | 62 ++- .../settings/integrations/detail/update.ts | 129 ++++-- .../integrations/detail/view-connection.ts | 42 +- .../page/settings/integrations/detail/view.ts | 4 +- .../settings/integrations/index/message.ts | 3 +- .../settings/integrations/index/scene.test.ts | 9 +- .../settings/integrations/index/story.test.ts | 2 +- .../settings/integrations/index/update.ts | 13 +- .../page/settings/integrations/index/view.ts | 16 +- .../integrations/installed/message.ts | 8 +- .../integrations/installed/scene.test.ts | 8 +- .../integrations/installed/story.test.ts | 4 +- .../settings/integrations/installed/update.ts | 45 +- .../integrations/marketplace/message.ts | 8 +- .../integrations/marketplace/scene.test.ts | 2 +- .../integrations/marketplace/story.test.ts | 6 +- .../integrations/marketplace/update.ts | 63 +-- .../integrations/your-apps/story.test.ts | 18 +- .../settings/integrations/your-apps/update.ts | 9 +- .../settings/integrations/your-apps/view.ts | 5 +- .../src/page/settings/invitations/message.ts | 9 +- .../src/page/settings/invitations/model.ts | 12 +- .../page/settings/invitations/page.test.ts | 10 +- .../page/settings/invitations/scene.test.ts | 80 +++- .../page/settings/invitations/story.test.ts | 34 +- .../src/page/settings/invitations/update.ts | 84 +++- .../src/page/settings/invitations/view.ts | 151 ++++-- .../src/page/settings/team/view.ts | 5 - 70 files changed, 3296 insertions(+), 1783 deletions(-) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts index 3a50b4834..5b0fe9254 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts @@ -1,10 +1,16 @@ +import * as AsyncData from "foldkit/asyncData" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { IconHashtag } from "../../../icons" import { button } from "../../../ui/button" import { dialogBody, dialogClose, dialogFooter, dialogHeader, dialogTitle } from "../../../ui/dialog" import { description, label } from "../../../ui/field" import * as Modal from "../../../ui/modal" -import type { DiscordChannel } from "../chat-sync/discord" +import { + type DiscordChannel, + type DiscordFailure, + discordFailureMessage, + isRetryable, +} from "../chat-sync/discord" import { filterByName, scrollList, @@ -140,33 +146,52 @@ const discordPicker = ( description(h, {}, ["Select the Discord channel from the connected server."]), ] +const loadingChannels = (h: HtmlBuilder<Message>): ReadonlyArray<Html> => [ + h.div( + [h.Class("flex items-center justify-center rounded-lg border border-border p-6")], + [spinnerLine(h, "Loading Discord channels...")], + ), +] + +/** Why the channels didn't load, with "Try again" when the reason is transient. */ +const loadFailed = (h: HtmlBuilder<Message>, reason: DiscordFailure): ReadonlyArray<Html> => { + const message = discordFailureMessage(reason, "channels") + return [ + h.div( + [h.Class("rounded-lg border border-border bg-bg-muted/20 p-4")], + [ + h.p([h.Class("font-medium text-fg text-sm")], [message.title]), + ...(message.description === undefined + ? [] + : [h.p([h.Class("mt-1 text-muted-fg text-sm")], [message.description])]), + ...(isRetryable(reason) + ? [ + button( + h, + { + intent: "secondary", + size: "sm", + className: "mt-3", + onPress: Message.ClickedRetryListDiscordChannels(), + }, + ["Try again"], + ), + ] + : []), + ], + ), + ] +} + const discordSection = (h: HtmlBuilder<Message>, model: Model): Html => { - const channels = model.discordChannels - const content = - channels._tag === "Loading" - ? [ - h.div( - [h.Class("flex items-center justify-center rounded-lg border border-border p-6")], - [spinnerLine(h, "Loading Discord channels...")], - ), - ] - : channels._tag === "Failed" - ? [ - h.div( - [h.Class("rounded-lg border border-border bg-bg-muted/20 p-4")], - [ - h.p( - [h.Class("font-medium text-fg text-sm")], - ["Could not load Discord channels"], - ), - h.p( - [h.Class("mt-1 text-muted-fg text-sm")], - ["Make sure the bot is installed in this server and has channel access."], - ), - ], - ), - ] - : discordPicker(h, model, channels.items) + const content = AsyncData.match(model.discordChannels, { + onIdle: () => loadingChannels(h), + onLoading: () => loadingChannels(h), + onFailure: (reason) => loadFailed(h, reason), + onRefreshing: (channels) => discordPicker(h, model, channels), + onStale: ({ data }) => discordPicker(h, model, data), + onSuccess: (channels) => discordPicker(h, model, channels), + }) return h.div([h.Class("flex flex-col gap-2")], [label(h, {}, ["Discord Channel"]), ...content]) } @@ -243,6 +268,7 @@ const content = ( closeAttributes: ReadonlyArray<ChildAttribute>, ): ReadonlyArray<Html> => { const isValid = model.selectedChannel !== null && model.selectedDiscordChannel !== null + const isSubmitting = model.submission._tag === "Submitting" return [ dialogHeader(h, {}, [dialogTitle(h, { id: Modal.titleId(model.addLinkModal.id) }, "Link Channel")]), dialogBody( @@ -257,11 +283,11 @@ const content = ( { intent: "primary", onPress: Message.ClickedCreateLink(), - isDisabled: !isValid || model.isCreatingLink || model.discordChannels._tag !== "Loaded", - isPending: model.isCreatingLink, + isDisabled: !isValid || isSubmitting || !AsyncData.isSuccess(model.discordChannels), + isPending: isSubmitting, interaction: { wiring: interaction.wiring(model), target: CREATE_LINK_TARGET }, }, - [model.isCreatingLink ? "Linking..." : "Link Channel"], + [isSubmitting ? "Linking..." : "Link Channel"], ), ]), ] diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/command.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/command.ts index 37658e8c5..d2b583dda 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/command.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/command.ts @@ -5,11 +5,11 @@ import { SyncChannelLinkId, SyncConnectionId, } from "@hazel/schema" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command, Render } from "foldkit" import * as Dom from "foldkit/dom" import { HazelRpc } from "../../../rpc" -import { failureToast } from "../../../data/actions" +import { catchCommon } from "../../../data/actions" import { fetchDiscordGuildChannels } from "../chat-sync/discord" import { fetchConnections } from "../chat-sync/rpc" import { @@ -21,21 +21,13 @@ import { WebhookPermission, } from "./model" -const linkNotFound = { - ChatSyncChannelLinkNotFoundError: { - title: "Link not found", - description: "This channel link may have already been removed.", - isRetryable: false, - }, -} - export const ListChatSyncConnectionsForConnection = Command.define("ListChatSyncConnectionsForConnection", { args: { organizationId: OrganizationId }, messages: [Message.SucceededListConnections, Message.FailedListConnections], execute: ({ organizationId }) => fetchConnections(organizationId).pipe( Effect.map((connections) => Message.SucceededListConnections({ organizationId, connections })), - Effect.catch(() => Effect.succeed(Message.FailedListConnections({ organizationId }))), + catchCommon((reason) => Message.FailedListConnections({ organizationId, reason })), ), }) @@ -46,7 +38,13 @@ export const ListDiscordChannels = Command.define("ListDiscordChannels", { execute: ({ organizationId, guildId }) => fetchDiscordGuildChannels(organizationId, guildId).pipe( Effect.map((channels) => Message.SucceededListDiscordChannels({ guildId, channels })), - Effect.catch(() => Effect.succeed(Message.FailedListDiscordChannels({ guildId }))), + Effect.catchTags({ + IntegrationNotConnectedForPreviewError: () => + Effect.succeed(Message.FailedListDiscordChannels({ guildId, reason: "NotConnected" })), + IntegrationResourceError: () => + Effect.succeed(Message.FailedListDiscordChannels({ guildId, reason: "ProviderError" })), + }), + catchCommon((reason) => Message.FailedListDiscordChannels({ guildId, reason })), ), }) @@ -80,45 +78,41 @@ export const ListChannelLinks = Command.define("ListChannelLinks", { }), ) return Message.SucceededListChannelLinks({ version, links }) - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedListChannelLinks({ version })))), + }).pipe( + Effect.catchTags({ + ChatSyncConnectionNotFoundError: () => + Effect.succeed(Message.FailedListChannelLinks({ version, reason: "ConnectionNotFound" })), + }), + catchCommon((reason) => Message.FailedListChannelLinks({ version, reason })), + ), }) export const DisconnectConnection = Command.define("DisconnectConnection", { args: { syncConnectionId: SyncConnectionId }, messages: [Message.SucceededDisconnect, Message.FailedDisconnect], execute: ({ syncConnectionId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("chatSync.connection.delete", { syncConnectionId })) - return Exit.match(exit, { - onSuccess: () => Message.SucceededDisconnect(), - onFailure: (cause) => - Message.FailedDisconnect({ - toast: failureToast(cause, "exitToast", { - ChatSyncConnectionNotFoundError: { - title: "Connection not found", - description: "This connection may have already been deleted.", - isRetryable: false, - }, - }), - }), - }) - }), + HazelRpc.use((rpc) => rpc("chatSync.connection.delete", { syncConnectionId })).pipe( + Effect.as(Message.SucceededDisconnect()), + Effect.catchTags({ + ChatSyncConnectionNotFoundError: () => + Effect.succeed(Message.FailedDisconnect({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedDisconnect({ reason })), + ), }) export const RemoveChannelLink = Command.define("RemoveChannelLink", { args: { syncChannelLinkId: SyncChannelLinkId }, messages: [Message.SucceededRemoveLink, Message.FailedRemoveLink], execute: ({ syncChannelLinkId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("chatSync.channelLink.delete", { syncChannelLinkId })) - return Exit.match(exit, { - onSuccess: () => Message.SucceededRemoveLink(), - onFailure: (cause) => - Message.FailedRemoveLink({ toast: failureToast(cause, "exitToast", linkNotFound) }), - }) - }), + HazelRpc.use((rpc) => rpc("chatSync.channelLink.delete", { syncChannelLinkId })).pipe( + Effect.as(Message.SucceededRemoveLink()), + Effect.catchTags({ + ChatSyncChannelLinkNotFoundError: () => + Effect.succeed(Message.FailedRemoveLink({ reason: "LinkNotFound" })), + }), + catchCommon((reason) => Message.FailedRemoveLink({ reason })), + ), }) /** `handleToggleLinkActive` and `handleChangeDirection` (`chatSync.channelLink.update`). */ @@ -129,35 +123,32 @@ export const UpdateChannelLink = Command.define("UpdateChannelLink", { direction: Schema.optional(SyncDirection), }, messages: [Message.SucceededUpdateLink, Message.FailedUpdateLink], - execute: ({ syncChannelLinkId, isActive, direction }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client( - "chatSync.channelLink.update", - direction === undefined - ? { syncChannelLinkId, isActive } - : { syncChannelLinkId, direction }, - ), - ) - const successMessage = - direction === undefined - ? isActive - ? "Channel link resumed" - : "Channel link paused" - : `Sync direction updated to ${DIRECTION_LABELS[direction]}` - return Exit.match(exit, { - onSuccess: () => Message.SucceededUpdateLink({ linkId: syncChannelLinkId, successMessage }), - onFailure: (cause) => - Message.FailedUpdateLink({ - linkId: syncChannelLinkId, - toast: failureToast(cause, "exitToast", linkNotFound), - }), - }) - }), + execute: ({ syncChannelLinkId, isActive, direction }) => { + const successMessage = + direction === undefined + ? isActive + ? "Channel link resumed" + : "Channel link paused" + : `Sync direction updated to ${DIRECTION_LABELS[direction]}` + return HazelRpc.use((rpc) => + rpc( + "chatSync.channelLink.update", + direction === undefined ? { syncChannelLinkId, isActive } : { syncChannelLinkId, direction }, + ), + ).pipe( + Effect.as(Message.SucceededUpdateLink({ linkId: syncChannelLinkId, successMessage })), + Effect.catchTags({ + ChatSyncChannelLinkNotFoundError: () => + Effect.succeed( + Message.FailedUpdateLink({ linkId: syncChannelLinkId, reason: "LinkNotFound" }), + ), + }), + catchCommon((reason) => Message.FailedUpdateLink({ linkId: syncChannelLinkId, reason })), + ) + }, }) -/** `AddChannelLinkModal.handleSubmit` (`chatSync.channelLink.create`) with its `exitToast` handlers. */ +/** `AddChannelLinkModal.handleSubmit` (`chatSync.channelLink.create`). */ export const CreateChannelLink = Command.define("CreateChannelLink", { args: { syncConnectionId: SyncConnectionId, @@ -173,29 +164,19 @@ export const CreateChannelLink = Command.define("CreateChannelLink", { // React paints the pending button on the click, before any reply; wait for that commit. yield* Render.afterCommit const client = yield* HazelRpc - const exit = yield* Effect.exit(client("chatSync.channelLink.create", payload)) - return Exit.match(exit, { - onSuccess: () => - Message.SucceededCreateLink({ - successMessage: `Linked #${hazelChannelName} to #${payload.externalChannelName}`, - }), - onFailure: (cause) => - Message.FailedCreateLink({ - toast: failureToast(cause, "exitToast", { - ChatSyncConnectionNotFoundError: { - title: "Connection not found", - description: "This sync connection may have been deleted.", - isRetryable: false, - }, - ChatSyncChannelLinkExistsError: { - title: "Link already exists", - description: "This channel pair is already linked.", - isRetryable: false, - }, - }), - }), + yield* client("chatSync.channelLink.create", payload) + return Message.SucceededCreateLink({ + successMessage: `Linked #${hazelChannelName} to #${payload.externalChannelName}`, }) - }), + }).pipe( + Effect.catchTags({ + ChatSyncConnectionNotFoundError: () => + Effect.succeed(Message.FailedCreateLink({ reason: "ConnectionNotFound" })), + ChatSyncChannelLinkExistsError: () => + Effect.succeed(Message.FailedCreateLink({ reason: "AlreadyLinked" })), + }), + catchCommon((reason) => Message.FailedCreateLink({ reason })), + ), }) /** The Hazel channel search's `autoFocus`, once it is rendered. */ diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/model.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/model.ts index 0273bb040..5592e521a 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/model.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/model.ts @@ -1,12 +1,15 @@ import { ChannelId, OrganizationId, SyncChannelLinkId, SyncConnectionId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason, failureMessage, type UserErrorMessage } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" import * as Menu from "../../../ui/menu" import * as Modal from "../../../ui/modal" -import { DiscordChannel, DiscordResource } from "../chat-sync/discord" -import { Connection } from "../chat-sync/model" +import * as ConfirmAction from "../../shared/confirm-action" +import { DiscordChannel, DiscordChannels, DiscordFailure } from "../chat-sync/discord" +import { Connection, DeleteConnectionFailure } from "../chat-sync/model" export const SyncDirection = Schema.Literals(["both", "hazel_to_external", "external_to_hazel"]) export type SyncDirection = typeof SyncDirection.Type @@ -25,28 +28,52 @@ export const ChannelLink = Schema.Struct({ }) export type ChannelLink = typeof ChannelLink.Type -/** The connection list query: loading, or the connection (none when it is not in the list). */ -export const ConnectionState = Schema.Union([ - Schema.TaggedStruct("Loading", {}), - Schema.TaggedStruct("Loaded", { connection: Schema.NullOr(Connection) }), -]) +/** The connection list query: the connection, or `null` when it is not in the list. */ +export const ConnectionLookup = AsyncData.Schema(Schema.NullOr(Connection), CommonReason) + +export const ListLinksFailure = Schema.Union([Schema.Literal("ConnectionNotFound"), CommonReason]) +export type ListLinksFailure = typeof ListLinksFailure.Type + +export const ChannelLinks = AsyncData.Schema(Schema.Array(ChannelLink), ListLinksFailure) + +export const listLinksFailureMessage = (reason: ListLinksFailure): UserErrorMessage => + failureMessage(reason, { + ConnectionNotFound: { + title: "Connection not found", + description: "This sync connection may have been deleted.", + }, + }) + +/** Removing, pausing, resuming or redirecting a link. */ +export const LinkFailure = Schema.Union([Schema.Literal("LinkNotFound"), CommonReason]) +export type LinkFailure = typeof LinkFailure.Type -export const LinksState = Schema.Union([ - Schema.TaggedStruct("Loading", {}), - Schema.TaggedStruct("Loaded", { links: Schema.Array(ChannelLink) }), +export const CreateLinkFailure = Schema.Union([ + Schema.Literals(["ConnectionNotFound", "AlreadyLinked"]), + CommonReason, ]) +export type CreateLinkFailure = typeof CreateLinkFailure.Type + +/** The link modal's Link Channel: not pressed yet, linking, or failed (the modal stays open to retry). */ +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Submitting: {}, + Failed: { reason: CreateLinkFailure }, +}) +export type Submission = typeof Submission.Type /** A Hazel channel as `AddChannelLinkModal` lists it. */ export const HazelChannel = Schema.Struct({ id: ChannelId, name: Schema.String }) export type HazelChannel = typeof HazelChannel.Type export const LinkTarget = Schema.Struct({ id: SyncChannelLinkId, name: Schema.String }) +export type LinkTarget = typeof LinkTarget.Type export const Model = Schema.Struct({ connectionId: SyncConnectionId, requestedOrganizationId: Schema.NullOr(OrganizationId), - connection: ConnectionState, - links: LinksState, + connection: ConnectionLookup.schema, + links: ChannelLinks.schema, /** Bumped per links request (every create / remove / update reloads); an older list is dropped. */ linksVersion: Schema.Number, /** Links with a pause / resume / direction update in flight; a repeat is ignored. */ @@ -56,7 +83,7 @@ export const Model = Schema.Struct({ linkMenus: Schema.Array(Menu.Model), /** `AddChannelLinkModal`: open state, its queries (run once the connection is found) and form. */ addLinkModal: Modal.Model, - discordChannels: DiscordResource(DiscordChannel), + discordChannels: DiscordChannels.schema, hazelChannels: Schema.Array(HazelChannel), selectedChannel: Schema.NullOr(HazelChannel), selectedDiscordChannel: Schema.NullOr(DiscordChannel), @@ -64,12 +91,10 @@ export const Model = Schema.Struct({ channelSearch: Schema.String, discordChannelSearch: Schema.String, focusedSearch: Schema.NullOr(Schema.Literals(["hazel", "discord"])), - isCreatingLink: Schema.Boolean, - deleteTarget: Schema.NullOr(LinkTarget), - deleteLinkModal: Modal.Model, - isDeletingLink: Schema.Boolean, - disconnectModal: Modal.Model, - isDisconnecting: Schema.Boolean, + submission: Submission, + removeLinkConfirm: ConfirmAction.Model(LinkTarget), + /** Disconnecting deletes this page's connection. */ + disconnectConfirm: ConfirmAction.Model(SyncConnectionId), /** Hover, press and focus of the link modal's Link Channel button. */ interaction: Interaction.Model, }) @@ -77,26 +102,29 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ SucceededListConnections: { organizationId: OrganizationId, connections: Schema.Array(Connection) }, - FailedListConnections: { organizationId: OrganizationId }, + FailedListConnections: { organizationId: OrganizationId, reason: CommonReason }, + ClickedRetryLoadConnection: {}, SucceededListDiscordChannels: { guildId: Schema.String, channels: Schema.Array(DiscordChannel) }, - FailedListDiscordChannels: { guildId: Schema.String }, + FailedListDiscordChannels: { guildId: Schema.String, reason: DiscordFailure }, + ClickedRetryListDiscordChannels: {}, SucceededListChannelLinks: { version: Schema.Number, links: Schema.Array(ChannelLink) }, - FailedListChannelLinks: { version: Schema.Number }, + FailedListChannelLinks: { version: Schema.Number, reason: ListLinksFailure }, + ClickedRetryListChannelLinks: {}, UpdatedChannelNames: { names: Schema.Record(Schema.String, Schema.String) }, ClickedBack: {}, ClickedDisconnect: {}, ClickedConfirmDisconnect: {}, SucceededDisconnect: {}, - FailedDisconnect: { toast: ToastRequest }, + FailedDisconnect: { reason: DeleteConnectionFailure }, ClickedLinkChannel: {}, ClickedConfirmRemoveLink: {}, SucceededRemoveLink: {}, SucceededUpdateLink: { linkId: SyncChannelLinkId, successMessage: Schema.String }, - FailedUpdateLink: { linkId: SyncChannelLinkId, toast: ToastRequest }, - FailedRemoveLink: { toast: ToastRequest }, + FailedUpdateLink: { linkId: SyncChannelLinkId, reason: LinkFailure }, + FailedRemoveLink: { reason: LinkFailure }, GotLinkMenuMessage: { linkId: SyncChannelLinkId, message: Menu.Message }, - GotDeleteLinkModalMessage: { message: Modal.Message }, - GotDisconnectModalMessage: { message: Modal.Message }, + GotRemoveLinkConfirmMessage: { message: ConfirmAction.Message }, + GotDisconnectConfirmMessage: { message: ConfirmAction.Message }, GotAddLinkModalMessage: { message: Modal.Message }, UpdatedHazelChannels: { channels: Schema.Array(HazelChannel) }, ChangedChannelSearch: { value: Schema.String }, @@ -111,11 +139,19 @@ export const Message = defineMessageUnion({ ClickedDirection: { direction: SyncDirection }, ClickedCreateLink: {}, SucceededCreateLink: { successMessage: Schema.String }, - FailedCreateLink: { toast: ToastRequest }, + FailedCreateLink: { reason: CreateLinkFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type +/** The connection, once the list has it (also while it refreshes). */ +export const foundConnection = (model: Model): Connection | null => + Option.getOrNull(AsyncData.getData(model.connection)) + +/** The listed links, or none while the first list loads or after it failed. */ +export const listedLinks = (model: Model): ReadonlyArray<ChannelLink> => + Option.getOrElse(AsyncData.getData(model.links), () => []) + export const DIRECTION_LABELS: Readonly<Record<SyncDirection, string>> = { both: "Both", hazel_to_external: "Hazel to Discord", diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts index eb3abe40a..a2a644855 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts @@ -6,11 +6,20 @@ import { describe, test } from "vitest" import * as Menu from "../../../ui/menu" import { FocusTriggerOnPress } from "../../../ui/menu-view" import * as Modal from "../../../ui/modal" -import { errorToast, successToast } from "../../../data/actions" +import { successToast } from "../../../data/actions" import { makeShared, organizationId, pageScene, portalModalMounted, uuid } from "../../../test/pages-fixtures" import { connection, syncConnectionId } from "../../../test/pages-integrations-fixtures" import { PageOutMessage } from "../../out-message" -import { CreateChannelLink, DisconnectConnection, ListChannelLinks, RemoveChannelLink } from "./command" +import * as ConfirmAction from "../../shared/confirm-action" +import { DiscordChannels } from "../chat-sync/discord" +import { + CreateChannelLink, + DisconnectConnection, + ListChannelLinks, + ListChatSyncConnectionsForConnection, + ListDiscordChannels, + RemoveChannelLink, +} from "./command" import { Message, type Model } from "./model" import { init, update } from "./update" import { view } from "./view" @@ -53,6 +62,7 @@ const withLink = step(found, Message.SucceededListChannelLinks({ version: 1, lin const withoutLinks = step(found, Message.SucceededListChannelLinks({ version: 1, links: [] })) const dialog = Scene.role("dialog") +const tryAgain = Scene.role("button", { name: "Try again" }) const inDialog = (name: string | RegExp) => Scene.within(dialog, Scene.role("button", { name })) /** Each link row's menu trigger focuses itself on press. */ const linkMenuMounted = Scene.Mount.resolve(FocusTriggerOnPress, Menu.Message.CompletedFocusTriggerOnPress()) @@ -84,6 +94,50 @@ describe("connection states", () => { ) }) + test("a failed connection list explains why, not 'not found', and Try again reloads it", () => { + Scene.scene( + config, + Scene.given(step(initial, Message.FailedListConnections({ organizationId, reason: "Offline" }))), + Scene.expect(Scene.text("Failed to load connection")).toExist(), + Scene.expect(Scene.text("Check your internet connection and try again.")).toExist(), + Scene.expect(Scene.text("Connection not found")).toBeAbsent(), + Scene.click(tryAgain), + Scene.Command.expectExact(ListChatSyncConnectionsForConnection({ organizationId })), + Scene.expect(Scene.text("Loading connection...")).toExist(), + Scene.Command.resolve( + ListChatSyncConnectionsForConnection, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ), + Scene.Command.resolve( + ListDiscordChannels, + Message.SucceededListDiscordChannels({ + guildId: connection.externalWorkspaceId, + channels: [], + }), + ), + Scene.expect(Scene.text("Channel Links")).toExist(), + ) + }) + + test("failed channel links explain why, not 'no channels linked', and Try again reloads them", () => { + Scene.scene( + config, + Scene.given(step(found, Message.FailedListChannelLinks({ version: 1, reason: "Unavailable" }))), + Scene.expect(Scene.text("Failed to load channel links")).toExist(), + Scene.expect(Scene.text("Please try again in a moment.")).toExist(), + Scene.expect(Scene.text("No channels linked")).toBeAbsent(), + Scene.click(tryAgain), + Scene.Command.expectExact(ListChannelLinks({ syncConnectionId, version: 2 })), + Scene.expect(Scene.text("Loading channel links...")).toExist(), + Scene.Command.resolve( + ListChannelLinks, + Message.SucceededListChannelLinks({ version: 2, links: [link] }), + ), + linkMenuMounted, + Scene.expect(Scene.text("general")).toExist(), + ) + }) + test("a linked channel shows its Hazel name, Discord name and status", () => { Scene.scene( config, @@ -121,36 +175,35 @@ describe("disconnect", () => { ) }) - test("a failed disconnect toasts the error and closes the dialog", () => { + test("a failed disconnect toasts the reason and keeps the dialog open to retry", () => { Scene.scene( config, Scene.given(withoutLinks), Scene.click(Scene.role("button", { name: "Disconnect" })), portalModalMounted, Scene.click(inDialog("Disconnect")), - Scene.Command.resolve( - DisconnectConnection, - Message.FailedDisconnect({ toast: errorToast("Request failed") }), - ), + Scene.Command.resolve(DisconnectConnection, Message.FailedDisconnect({ reason: "Unexpected" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Request failed", description: null }, + toast: { + intent: "error", + title: "Something went wrong", + description: "Please try again.", + }, }), ), - Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), - Scene.expect(Scene.role("button", { name: "Disconnect" })).toBeEnabled(), + Scene.expect(inDialog("Disconnect")).toBeEnabled(), ) }) }) describe("remove a channel link", () => { - const confirming = step( - { ...withLink, deleteTarget: { id: linkId, name: "general" } }, - Message.GotDeleteLinkModalMessage({ message: Modal.Message.ClickedTrigger() }), - ) + const confirming = { + ...withLink, + removeLinkConfirm: ConfirmAction.ask(withLink.removeLinkConfirm, { id: linkId, name: "general" }), + } - test("confirming removes the link, toasts and reloads the links", () => { + test("confirming removes the link, toasts and refreshes the shown links", () => { Scene.scene( config, Scene.given(confirming), @@ -165,12 +218,13 @@ describe("remove a channel link", () => { ), Scene.expect(dialog).toBeAbsent(), Scene.Mount.expectEnded(Modal.PortalModal), - Scene.expect(Scene.text("Loading channel links...")).toExist(), - Scene.Mount.expectEnded(FocusTriggerOnPress), + Scene.expect(Scene.text("Loading channel links...")).toBeAbsent(), + Scene.expect(Scene.text("general")).toExist(), Scene.Command.resolve( ListChannelLinks, Message.SucceededListChannelLinks({ version: 2, links: [] }), ), + Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.expect(Scene.text("No channels linked")).toExist(), ) }) @@ -182,13 +236,14 @@ describe("remove a channel link", () => { linkMenuMounted, portalModalMounted, Scene.click(inDialog("Remove Link")), - Scene.Command.resolve( - RemoveChannelLink, - Message.FailedRemoveLink({ toast: errorToast("Request failed") }), - ), + Scene.Command.resolve(RemoveChannelLink, Message.FailedRemoveLink({ reason: "Offline" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Request failed", description: null }, + toast: { + intent: "error", + title: "Connection lost", + description: "Check your internet connection and try again.", + }, }), ), Scene.expect(inDialog("Remove Link")).toBeEnabled(), @@ -251,13 +306,14 @@ describe("link a channel", () => { Scene.click(Scene.role("button", { name: /Link Channel$/ })), addLinkModalMounted, Scene.click(linkButton), - Scene.Command.resolve( - CreateChannelLink, - Message.FailedCreateLink({ toast: errorToast("Already linked") }), - ), + Scene.Command.resolve(CreateChannelLink, Message.FailedCreateLink({ reason: "AlreadyLinked" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Already linked", description: null }, + toast: { + intent: "error", + title: "Link already exists", + description: "This channel pair is already linked.", + }, }), ), Scene.expect(Scene.within(dialog, Scene.text("dev-chat"))).toExist(), @@ -270,12 +326,13 @@ describe("link a channel", () => { config, Scene.given({ ...withoutLinks, - discordChannels: { _tag: "Failed" }, + discordChannels: DiscordChannels.Failure({ error: "ProviderError" }), selectedChannel: hazelChannel, }), Scene.click(Scene.role("button", { name: /Link Channel$/ })), addLinkModalMounted, Scene.expect(Scene.text("Could not load Discord channels")).toExist(), + Scene.expect(Scene.within(dialog, tryAgain)).toBeAbsent(), Scene.expect(linkButton).toBeDisabled(), ) }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts index 490f5efc1..1cf818dd5 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/story.test.ts @@ -1,4 +1,4 @@ -import "../../channel-settings/document-stub" +import "../../../test/document-stub" import { ChannelId, ExternalChannelId, @@ -6,13 +6,14 @@ import { SyncChannelLinkId, SyncConnectionId, } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { errorToast } from "../../../data/actions" import * as Menu from "../../../ui/menu" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" +import * as ConfirmAction from "../../shared/confirm-action" +import { DiscordChannels } from "../chat-sync/discord" import { CreateChannelLink, DisconnectConnection, @@ -22,7 +23,7 @@ import { RemoveChannelLink, UpdateChannelLink, } from "./command" -import { Message } from "./model" +import { ChannelLinks, ConnectionLookup, Message, Submission } from "./model" import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" @@ -47,6 +48,14 @@ const link = { isActive: true, webhookPermission: "allowed" as const, } +const connection = { + id: connectionId, + displayName: "Hazel Community", + status: "active" as const, + externalWorkspaceId: "918273645500120", + errorMessage: null, + lastSyncedAtMs: null, +} const run = (current: Parameters<typeof update>[0], next: Message) => update(current, next, shared) const route = { _tag: "SettingsChatSyncConnection" as const, orgSlug: "hazel", connectionId } const loaded = run( @@ -62,7 +71,7 @@ describe("chat sync connection", () => { ]) }) - test("Remove link from the row menu confirms, removes and reloads the links", () => { + test("Remove link from the row menu confirms, removes and refreshes the shown links", () => { story( run, given(loaded), @@ -77,8 +86,11 @@ describe("chat sync connection", () => { Message.GotLinkMenuMessage({ linkId, message: Menu.Message.ClickedItem({ key: "remove" }) }), ), model((current) => { - expect(current.deleteTarget).toEqual({ id: linkId, name: "general" }) - expect(current.deleteLinkModal.isOpen).toBe(true) + expect(current.removeLinkConfirm.state).toEqual({ + _tag: "Confirming", + target: { id: linkId, name: "general" }, + }) + expect(current.removeLinkConfirm.modal.isOpen).toBe(true) }), Command.resolveAll(), message(Message.ClickedConfirmRemoveLink()), @@ -88,7 +100,10 @@ describe("chat sync connection", () => { toast: { intent: "success", title: "Channel link removed", description: null }, }), ), - model((current) => expect(current.links._tag).toBe("Loading")), + model((current) => { + expect(current.removeLinkConfirm.state._tag).toBe("Closed") + expect(current.links).toEqual(ChannelLinks.Refreshing({ data: [link] })) + }), Command.resolve(ListChannelLinks, Message.SucceededListChannelLinks({ version: 2, links: [] })), ) }) @@ -99,6 +114,7 @@ describe("chat sync connection", () => { given(loaded), message(Message.ClickedDisconnect()), message(Message.ClickedConfirmDisconnect()), + Command.expectExact(DisconnectConnection({ syncConnectionId: connectionId })), Command.resolve(DisconnectConnection, Message.SucceededDisconnect()), expectOutMessage( PageOutMessage.RequestedNavigation({ @@ -107,37 +123,91 @@ describe("chat sync connection", () => { toast: { intent: "success", title: "Connection deleted", description: null }, }), ), - model((current) => expect(current.disconnectModal.isOpen).toBe(false)), + model((current) => expect(current.disconnectConfirm.modal.isOpen).toBe(false)), ) }) test("an older links list is dropped once a reload is out", () => { - const reloading = { ...loaded, linksVersion: 2, links: { _tag: "Loading" as const } } + const reloading = { ...loaded, linksVersion: 2, links: ChannelLinks.Loading() } expect( run(reloading, Message.SucceededListChannelLinks({ version: 1, links: [] })).model.links, - ).toEqual({ - _tag: "Loading", - }) - expect(run(reloading, Message.FailedListChannelLinks({ version: 1 })).model.links).toEqual({ - _tag: "Loading", - }) + ).toEqual(ChannelLinks.Loading()) + expect( + run(reloading, Message.FailedListChannelLinks({ version: 1, reason: "Offline" })).model.links, + ).toEqual(ChannelLinks.Loading()) expect( run(reloading, Message.SucceededListChannelLinks({ version: 2, links: [link] })).model.links, - ).toEqual({ - _tag: "Loaded", - links: [link], - }) + ).toEqual(ChannelLinks.Success({ data: [link] })) + }) + + test("a failed first links list is a failure with a retry, not an empty list", () => { + story( + run, + given(init(route, shared).model), + message(Message.FailedListChannelLinks({ version: 1, reason: "Unavailable" })), + model((current) => { + expect(current.links).toEqual(ChannelLinks.Failure({ error: "Unavailable" })) + expect(current.linkMenus).toEqual([]) + }), + message(Message.ClickedRetryListChannelLinks()), + Command.expectExact(ListChannelLinks({ syncConnectionId: connectionId, version: 2 })), + model((current) => expect(current.links).toEqual(ChannelLinks.Loading())), + Command.resolve( + ListChannelLinks, + Message.SucceededListChannelLinks({ version: 2, links: [link] }), + ), + model((current) => expect(current.links).toEqual(ChannelLinks.Success({ data: [link] }))), + ) + }) + + test("a failed links reload keeps the shown links and toasts", () => { + const reloading = { ...loaded, linksVersion: 2, links: ChannelLinks.Refreshing({ data: [link] }) } + const failed = run( + reloading, + Message.FailedListChannelLinks({ version: 2, reason: "ConnectionNotFound" }), + ) + expect(failed.model.links).toEqual(ChannelLinks.Stale({ error: "ConnectionNotFound", data: [link] })) + expect(failed.model.linkMenus).toHaveLength(1) + expect(failed.outMessage).toEqual( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Connection not found", + description: "This sync connection may have been deleted.", + }, + }), + ) + }) + + test("a failed connection list is a failure with a retry, not 'connection not found'", () => { + story( + run, + given(init(route, shared).model), + message(Message.FailedListConnections({ organizationId, reason: "Offline" })), + model((current) => + expect(current.connection).toEqual(ConnectionLookup.Failure({ error: "Offline" })), + ), + message(Message.ClickedRetryLoadConnection()), + Command.expectExact(ListChatSyncConnectionsForConnection({ organizationId })), + model((current) => expect(current.connection).toEqual(ConnectionLookup.Loading())), + Command.resolve( + ListChatSyncConnectionsForConnection, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ), + Command.resolve( + ListDiscordChannels, + Message.SucceededListDiscordChannels({ + guildId: connection.externalWorkspaceId, + channels: [], + }), + ), + model((current) => + expect(current.connection).toEqual(ConnectionLookup.Success({ data: connection })), + ), + ) }) test("a found connection mounts the link modal, which lists its guild's Discord channels", () => { - const connection = { - id: connectionId, - displayName: "Hazel Community", - status: "active" as const, - externalWorkspaceId: "918273645500120", - errorMessage: null, - lastSyncedAtMs: null, - } const channel = { id: Schema.decodeSync(ExternalChannelId)("1"), guildId: "918273645500120", @@ -154,7 +224,9 @@ describe("chat sync connection", () => { ListDiscordChannels, Message.SucceededListDiscordChannels({ guildId: "918273645500120", channels: [channel] }), ), - model((current) => expect(current.discordChannels).toEqual({ _tag: "Loaded", items: [channel] })), + model((current) => + expect(current.discordChannels).toEqual(DiscordChannels.Success({ data: [channel] })), + ), ) const listing = run( init(route, shared).model, @@ -164,7 +236,28 @@ describe("chat sync connection", () => { listing.model, Message.SucceededListDiscordChannels({ guildId: "1", channels: [channel] }), ) - expect(otherGuild.model.discordChannels).toEqual({ _tag: "Loading" }) + expect(otherGuild.model.discordChannels).toEqual(DiscordChannels.Loading()) + }) + + test("failed Discord channels keep their reason, and Try again lists them again", () => { + const listing = run( + init(route, shared).model, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ).model + story( + run, + given(listing), + message(Message.FailedListDiscordChannels({ guildId: "918273645500120", reason: "Timeout" })), + model((current) => + expect(current.discordChannels).toEqual(DiscordChannels.Failure({ error: "Timeout" })), + ), + message(Message.ClickedRetryListDiscordChannels()), + Command.expectExact(ListDiscordChannels({ organizationId, guildId: "918273645500120" })), + Command.resolve( + ListDiscordChannels, + Message.SucceededListDiscordChannels({ guildId: "918273645500120", channels: [] }), + ), + ) }) test("a missing connection sends no channel query", () => { @@ -173,9 +266,10 @@ describe("chat sync connection", () => { Message.SucceededListConnections({ organizationId, connections: [] }), ) expect(missing.commands ?? []).toHaveLength(0) + expect(missing.model.connection).toEqual(ConnectionLookup.Success({ data: null })) }) - test("linking a picked pair closes the modal, toasts, and reloads the links", () => { + test("linking a picked pair closes the modal, toasts, and refreshes the links", () => { const hazel = { id: link.hazelChannelId, name: "random" } const discord = { id: Schema.decodeSync(ExternalChannelId)("555000111222401"), @@ -219,7 +313,8 @@ describe("chat sync connection", () => { expect(current.addLinkModal.isOpen).toBe(false) expect(current.selectedChannel).toBeNull() expect(current.direction).toBe("both") - expect(current.links._tag).toBe("Loading") + expect(current.submission).toEqual(Submission.NotSubmitted()) + expect(current.links._tag).toBe("Refreshing") }), Command.resolve(ListChannelLinks, Message.SucceededListChannelLinks({ version: 2, links: [] })), ) @@ -256,7 +351,7 @@ describe("chat sync connection guards and link actions", () => { Message.SucceededListChannelLinks({ version: 2, links: [{ ...link, isActive: false }] }), ]), model((current) => - expect(current.links).toEqual({ _tag: "Loaded", links: [{ ...link, isActive: false }] }), + expect(current.links).toEqual(ChannelLinks.Success({ data: [{ ...link, isActive: false }] })), ), ) }) @@ -266,35 +361,48 @@ describe("chat sync connection guards and link actions", () => { expect(updating.updatingLinkIds).toEqual([linkId]) const again = run(run(updating, openMenu).model, pick("toggle")) expect(again.commands?.some((command) => command.name === UpdateChannelLink.name)).toBe(false) - const failed = run(updating, Message.FailedUpdateLink({ linkId, toast: errorToast("Failed") })) + const failed = run(updating, Message.FailedUpdateLink({ linkId, reason: "Unexpected" })) expect(failed.model.updatingLinkIds).toEqual([]) const retried = run(run(failed.model, openMenu).model, pick("toggle")) expect(retried.commands?.some((command) => command.name === UpdateChannelLink.name)).toBe(true) }) - test("a failed link action toasts and leaves the links loaded", () => { + test("a failed link action toasts its reason and leaves the links loaded", () => { story( run, given(loaded), - message(Message.FailedUpdateLink({ linkId, toast: errorToast("Channel link not found") })), + message(Message.FailedUpdateLink({ linkId, reason: "LinkNotFound" })), Command.expectNone(), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Channel link not found", description: null }, + toast: { + intent: "error", + title: "Link not found", + description: "This channel link may have already been removed.", + }, }), ), - model((current) => expect(current.links._tag).toBe("Loaded")), + model((current) => expect(current.links._tag).toBe("Success")), ) }) test("a second confirm while disconnecting or removing sends nothing", () => { - const disconnecting = run({ ...loaded, isDisconnecting: true }, Message.ClickedConfirmDisconnect()) - expect(disconnecting.commands ?? []).toHaveLength(0) - const removing = run( - { ...loaded, deleteTarget: { id: linkId, name: "general" }, isDeletingLink: true }, - Message.ClickedConfirmRemoveLink(), + const [disconnecting] = Option.getOrThrow( + ConfirmAction.confirm(ConfirmAction.ask(loaded.disconnectConfirm, connectionId)), ) - expect(removing.commands ?? []).toHaveLength(0) + expect( + run({ ...loaded, disconnectConfirm: disconnecting }, Message.ClickedConfirmDisconnect()) + .commands ?? [], + ).toHaveLength(0) + const [removing] = Option.getOrThrow( + ConfirmAction.confirm( + ConfirmAction.ask(loaded.removeLinkConfirm, { id: linkId, name: "general" }), + ), + ) + expect( + run({ ...loaded, removeLinkConfirm: removing }, Message.ClickedConfirmRemoveLink()).commands ?? + [], + ).toHaveLength(0) story(run, given(loaded), message(Message.ClickedConfirmRemoveLink()), Command.expectNone()) }) @@ -303,7 +411,7 @@ describe("chat sync connection guards and link actions", () => { story( run, given(init(route, shared).model), - message(Message.FailedListConnections({ organizationId: other })), + message(Message.FailedListConnections({ organizationId: other, reason: "Offline" })), model((current) => expect(current.connection._tag).toBe("Loading")), ) }) @@ -316,7 +424,7 @@ describe("chat sync connection guards and link actions", () => { message(Message.ClickedHazelChannel({ channel: hazelChannel })), message(Message.ClickedCreateLink()), Command.expectNone(), - model((current) => expect(current.isCreatingLink).toBe(false)), + model((current) => expect(current.submission).toEqual(Submission.NotSubmitted())), ) }) }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/subscription.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/subscription.ts index 1a90069d8..909a4c7eb 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/subscription.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/subscription.ts @@ -5,7 +5,7 @@ import { Subscription } from "foldkit" import { channelCollection } from "~/db/collections" import { liveQueryStream } from "../../../data/live-query" import type { PageSubscriptionInput } from "../../contract" -import { Message, type Model } from "./model" +import { foundConnection, listedLinks, Message, type Model } from "./model" import { interaction } from "./update" interface ChannelNameRow { @@ -15,9 +15,7 @@ interface ChannelNameRow { /** Joined with "," so the dependency is a stable string. */ const linkedChannelIds = (model: Model) => - model.links._tag === "Loaded" - ? [...new Set(model.links.links.map((link) => link.hazelChannelId))].sort().join(",") - : "" + [...new Set(listedLinks(model).map((link) => link.hazelChannelId))].sort().join(",") const dataSubscriptions = Subscription.make<PageSubscriptionInput<Model>, Message>()((entry) => ({ // `ChannelLinkRow`'s lookup of the Hazel channel name, once for every row. @@ -46,10 +44,7 @@ const dataSubscriptions = Subscription.make<PageSubscriptionInput<Model>, Messag { organizationId: Schema.NullOr(OrganizationId) }, { modelToDependencies: ({ model }) => ({ - organizationId: - model.connection._tag === "Loaded" && model.connection.connection !== null - ? model.requestedOrganizationId - : null, + organizationId: foundConnection(model) === null ? null : model.requestedOrganizationId, }), dependenciesToStream: ({ organizationId }) => organizationId === null diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts index 1fab85984..1cedb291a 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts @@ -1,15 +1,19 @@ +import type { OrganizationId, SyncConnectionId } from "@hazel/schema" import { Array, Option } from "effect" import { Command, Update } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import type { ToastRequest } from "../../../overlay/toasts" import { AppRoute, type RouteOf } from "../../../route" import * as Interaction from "../../../ui/aria/interaction" import * as Menu from "../../../ui/menu" import * as Modal from "../../../ui/modal" -import { successToast } from "../../../data/actions" -import type { PageReturn, Shared } from "../../contract" +import { errorToastOf, reasonToast, successToast } from "../../../data/actions" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage, requestedOrgNavigation } from "../../out-message" -import { embedInteraction } from "../integrations/shared/interaction" +import * as ConfirmAction from "../../shared/confirm-action" +import { DiscordChannels } from "../chat-sync/discord" import { CreateChannelLink, DisconnectConnection, @@ -23,29 +27,45 @@ import { import { ADD_LINK_MODAL_ID, type ChannelLink, + ChannelLinks, + ConnectionLookup, + foundConnection, + type LinkFailure, + type LinkTarget, linkMenuEntries, linkMenuId, + listedLinks, + listLinksFailureMessage, Message, type Model, + Submission, SyncDirection, } from "./model" type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) /** The link modal's Link Channel button. */ export const CREATE_LINK_TARGET = "create-link" +/** Lists the organization's connections to find this one; a shown connection stays while it reloads. */ +const loadConnection = (model: Model, organizationId: OrganizationId): Return => ({ + model: modifyFields(model, { + requestedOrganizationId: () => organizationId, + connection: (connection) => + Option.getOrElse(AsyncData.revalidateOrLoad(connection), () => connection), + }), + commands: [ListChatSyncConnectionsForConnection({ organizationId })], +}) + const requestConnections = (model: Model, shared: Shared): Return => { const organizationId = shared.organization?.id ?? null if (organizationId === null || organizationId === model.requestedOrganizationId) return { model } - return { - model: modifyFields(model, { requestedOrganizationId: () => organizationId }), - commands: [ListChatSyncConnectionsForConnection({ organizationId })], - } + return loadConnection(model, organizationId) } export const init = (route: RouteOf<"SettingsChatSyncConnection">, shared: Shared): Return => { @@ -53,14 +73,14 @@ export const init = (route: RouteOf<"SettingsChatSyncConnection">, shared: Share { connectionId: route.connectionId, requestedOrganizationId: null, - connection: { _tag: "Loading" }, - links: { _tag: "Loading" }, + connection: ConnectionLookup.Loading(), + links: ChannelLinks.Loading(), linksVersion: 1, updatingLinkIds: [], channelNames: {}, linkMenus: [], addLinkModal: Modal.init(ADD_LINK_MODAL_ID), - discordChannels: { _tag: "Loading" }, + discordChannels: DiscordChannels.Loading(), hazelChannels: [], selectedChannel: null, selectedDiscordChannel: null, @@ -68,12 +88,9 @@ export const init = (route: RouteOf<"SettingsChatSyncConnection">, shared: Share channelSearch: "", discordChannelSearch: "", focusedSearch: null, - isCreatingLink: false, - deleteTarget: null, - deleteLinkModal: Modal.init("chat-sync-remove-link"), - isDeletingLink: false, - disconnectModal: Modal.init("chat-sync-disconnect"), - isDisconnecting: false, + submission: Submission.NotSubmitted(), + removeLinkConfirm: ConfirmAction.init("chat-sync-remove-link"), + disconnectConfirm: ConfirmAction.init("chat-sync-disconnect"), interaction: Interaction.init(), }, shared, @@ -89,28 +106,18 @@ export const init = (route: RouteOf<"SettingsChatSyncConnection">, shared: Share export const sharedChanged = (model: Model, shared: Shared): Return => requestConnections(model, shared) -/** `setRefreshKey(k => k + 1)`: a new query key, so the links load again. */ +/** `setRefreshKey(k => k + 1)`: a new query key, so the links load again (the shown ones stay meanwhile). */ const reloadLinks = (model: Model): Pick<Return, "model" | "commands"> => { const version = model.linksVersion + 1 return { model: modifyFields(model, { - links: () => ({ _tag: "Loading" as const }), + links: (links) => Option.getOrElse(AsyncData.revalidateOrLoad(links), () => links), linksVersion: () => version, }), commands: [ListChannelLinks({ syncConnectionId: model.connectionId, version })], } } -const withCommands = ( - result: Pick<Return, "model" | "commands">, - commands: Return["commands"], -): Pick<Return, "model" | "commands"> => ({ - model: result.model, - commands: [...(result.commands ?? []), ...(commands ?? [])], -}) - -const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) - /** Keeps one menu per link, preserving the state of menus whose link is still listed. */ const reflectLinkMenus = (model: Model, links: ReadonlyArray<ChannelLink>): Model => modifyFields(model, { @@ -128,28 +135,31 @@ const reflectLinkMenus = (model: Model, links: ReadonlyArray<ChannelLink>): Mode ), }) -const deleteLinkModal = { - read: (model: Model) => Option.some(model.deleteLinkModal), - // A closed dialog forgets its target (`onOpenChange(false)`). - write: (model: Model, modal: Modal.Model): Model => - modifyFields(model, { - deleteLinkModal: () => modal, - deleteTarget: (target) => (modal.isOpen ? target : null), - }), - toParentMessage: (message: Modal.Message) => Message.GotDeleteLinkModalMessage({ message }), -} -const foldDeleteLinkModal = Update.foldChild({ update: Modal.update, ...deleteLinkModal }) -const openDeleteLinkModal = Update.foldChildStep({ update: Modal.open, ...deleteLinkModal }) -const closeDeleteLinkModal = Update.foldChildStep({ update: Modal.close, ...deleteLinkModal }) +const foldRemoveLinkConfirm = Update.foldChild({ + update: (confirm: ConfirmAction.Model<LinkTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(confirm, message), + read: (model: Model) => Option.some(model.removeLinkConfirm), + write: (model: Model, removeLinkConfirm: ConfirmAction.Model<LinkTarget>): Model => + modifyFields(model, { removeLinkConfirm: () => removeLinkConfirm }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotRemoveLinkConfirmMessage({ message }), +}) -const disconnectModal = { - read: (model: Model) => Option.some(model.disconnectModal), - write: (model: Model, modal: Modal.Model): Model => modifyFields(model, { disconnectModal: () => modal }), - toParentMessage: (message: Modal.Message) => Message.GotDisconnectModalMessage({ message }), -} -const foldDisconnectModal = Update.foldChild({ update: Modal.update, ...disconnectModal }) -const openDisconnectModal = Update.foldChildStep({ update: Modal.open, ...disconnectModal }) -const closeDisconnectModal = Update.foldChildStep({ update: Modal.close, ...disconnectModal }) +const withRemoveLinkConfirm = (model: Model, removeLinkConfirm: ConfirmAction.Model<LinkTarget>): Model => + modifyFields(model, { removeLinkConfirm: () => removeLinkConfirm }) + +const foldDisconnectConfirm = Update.foldChild({ + update: (confirm: ConfirmAction.Model<SyncConnectionId>, message: ConfirmAction.Message) => + ConfirmAction.update(confirm, message), + read: (model: Model) => Option.some(model.disconnectConfirm), + write: (model: Model, disconnectConfirm: ConfirmAction.Model<SyncConnectionId>): Model => + modifyFields(model, { disconnectConfirm: () => disconnectConfirm }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotDisconnectConfirmMessage({ message }), +}) + +const withDisconnectConfirm = ( + model: Model, + disconnectConfirm: ConfirmAction.Model<SyncConnectionId>, +): Model => modifyFields(model, { disconnectConfirm: () => disconnectConfirm }) const isDirection = (key: string): key is SyncDirection => SyncDirection.literals.some((literal) => literal === key) @@ -157,9 +167,12 @@ const isDirection = (key: string): key is SyncDirection => /** A link menu's `onAction`s. */ const selectedLinkAction = (model: Model, link: ChannelLink, key: string): Return => { if (key === "remove") - return openDeleteLinkModal( - modifyFields(model, { deleteTarget: () => ({ id: link.id, name: link.externalName }) }), - ) + return { + model: withRemoveLinkConfirm( + model, + ConfirmAction.ask(model.removeLinkConfirm, { id: link.id, name: link.externalName }), + ), + } if (key !== "toggle" && !isDirection(key)) return { model } // One update per link at a time; the menu stays enabled as in legacy. if (model.updatingLinkIds.includes(link.id)) return { model } @@ -186,8 +199,7 @@ const foldLinkMenu = (model: Model, linkId: ChannelLink["id"], message: Menu.Mes const commands = Command.mapMessages(next.commands ?? [], (child) => Message.GotLinkMenuMessage({ linkId, message: child }), ) - const links = model.links._tag === "Loaded" ? model.links.links : [] - const link = Array.findFirst(links, (candidate) => candidate.id === linkId) + const link = Array.findFirst(listedLinks(model), (candidate) => candidate.id === linkId) const selected = Option.flatMap(Option.fromNullishOr(next.outMessage), (out) => out._tag === "SelectedItem" ? Option.some(out.key) : Option.none(), ) @@ -196,7 +208,7 @@ const foldLinkMenu = (model: Model, linkId: ChannelLink["id"], message: Menu.Mes return { ...action, commands: [...commands, ...(action.commands ?? [])] } } -/** `handleClose`: a closed link modal has an empty form. */ +/** `handleClose`: a closed link modal has an empty form and no failed attempt. */ const addLinkModal = { read: (model: Model) => Option.some(model.addLinkModal), write: (model: Model, modal: Modal.Model): Model => @@ -210,6 +222,8 @@ const addLinkModal = { channelSearch: () => "", discordChannelSearch: () => "", focusedSearch: () => null, + submission: (submission) => + submission._tag === "Failed" ? Submission.NotSubmitted() : submission, }), toParentMessage: (message: Modal.Message) => Message.GotAddLinkModalMessage({ message }), } @@ -221,11 +235,12 @@ const closeAddLinkModal = Update.foldChildStep({ update: Modal.close, ...addLink const submitLink = (model: Model): Return => { const channel = model.selectedChannel const discordChannel = model.selectedDiscordChannel - if (channel === null || discordChannel === null || model.isCreatingLink) return { model } + if (channel === null || discordChannel === null || model.submission._tag === "Submitting") + return { model } return { // Link Channel disables while it runs, which ends its hover (useHover). model: modifyFields(model, { - isCreatingLink: () => true, + submission: () => Submission.Submitting(), interaction: (state) => Interaction.disabledTargets(state, [CREATE_LINK_TARGET]), }), commands: [ @@ -243,87 +258,148 @@ const submitLink = (model: Model): Return => { /** The Discord channel list belongs to the guild of the connection currently shown. */ const isCurrentGuild = (model: Model, guildId: string) => - model.connection._tag === "Loaded" && model.connection.connection?.externalWorkspaceId === guildId + foundConnection(model)?.externalWorkspaceId === guildId + +const requestDiscordChannels = (model: Model): Return => { + const organizationId = model.requestedOrganizationId + const connection = foundConnection(model) + if (organizationId === null || connection === null) return { model } + return { + model: modifyFields(model, { discordChannels: () => DiscordChannels.Loading() }), + commands: [ListDiscordChannels({ organizationId, guildId: connection.externalWorkspaceId })], + } +} const toList = (shared: Shared, toast?: ToastRequest) => requestedOrgNavigation(shared.orgSlug, (orgSlug) => AppRoute.SettingsChatSync({ orgSlug }), { toast }) +const linkFailureToast = (reason: LinkFailure) => + PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + LinkNotFound: { + title: "Link not found", + description: "This channel link may have already been removed.", + }, + }), + ), + }) + export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { SucceededListConnections: ({ organizationId, connections }) => { if (organizationId !== model.requestedOrganizationId) return { model } const connection = connections.find((found) => found.id === model.connectionId) ?? null const next = modifyFields(model, { - connection: () => ({ _tag: "Loaded" as const, connection }), + connection: () => ConnectionLookup.Success({ data: connection }), }) // `AddChannelLinkModal` mounts once the connection is found, and queries its guild. - const previous = model.connection._tag === "Loaded" ? model.connection.connection : null + const previous = foundConnection(model) if (connection === null || previous?.externalWorkspaceId === connection.externalWorkspaceId) return { model: next } - return { - model: modifyFields(next, { discordChannels: () => ({ _tag: "Loading" as const }) }), - commands: [ListDiscordChannels({ organizationId, guildId: connection.externalWorkspaceId })], - } + return requestDiscordChannels(next) }, + // A failed list is an error to show (and retry), not a missing connection. + FailedListConnections: ({ organizationId, reason }) => { + if (organizationId !== model.requestedOrganizationId) return { model } + return Option.match(AsyncData.getData(model.connection), { + onSome: (data) => ({ + model: modifyFields(model, { + connection: () => ConnectionLookup.Stale({ error: reason, data }), + }), + outMessage: PageOutMessage.RequestedToast({ toast: reasonToast(reason) }), + }), + onNone: () => ({ + model: modifyFields(model, { + connection: () => ConnectionLookup.Failure({ error: reason }), + }), + }), + }) + }, + ClickedRetryLoadConnection: () => + model.requestedOrganizationId === null + ? { model } + : loadConnection(model, model.requestedOrganizationId), SucceededListDiscordChannels: ({ guildId, channels }) => isCurrentGuild(model, guildId) ? { model: modifyFields(model, { - discordChannels: () => ({ _tag: "Loaded" as const, items: channels }), + discordChannels: () => DiscordChannels.Success({ data: channels }), }), } : { model }, - FailedListDiscordChannels: ({ guildId }) => + FailedListDiscordChannels: ({ guildId, reason }) => isCurrentGuild(model, guildId) - ? { model: modifyFields(model, { discordChannels: () => ({ _tag: "Failed" as const }) }) } - : { model }, - // A failed query is not "initial", and no connection is found in it. - FailedListConnections: ({ organizationId }) => - organizationId === model.requestedOrganizationId ? { model: modifyFields(model, { - connection: () => ({ _tag: "Loaded" as const, connection: null }), + discordChannels: () => DiscordChannels.Failure({ error: reason }), }), } : { model }, + ClickedRetryListDiscordChannels: () => requestDiscordChannels(model), SucceededListChannelLinks: ({ version, links }) => version !== model.linksVersion ? { model } : { model: reflectLinkMenus( - modifyFields(model, { links: () => ({ _tag: "Loaded" as const, links }) }), + modifyFields(model, { links: () => ChannelLinks.Success({ data: links }) }), links, ), }, - FailedListChannelLinks: ({ version }) => - version !== model.linksVersion - ? { model } - : { - model: reflectLinkMenus( - modifyFields(model, { links: () => ({ _tag: "Loaded" as const, links: [] }) }), - [], - ), - }, + // A failed first list shows the error with a retry; a failed reload keeps the shown links. + FailedListChannelLinks: ({ version, reason }) => { + if (version !== model.linksVersion) return { model } + return Option.match(AsyncData.getData(model.links), { + onSome: (data) => ({ + model: modifyFields(model, { links: () => ChannelLinks.Stale({ error: reason, data }) }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(listLinksFailureMessage(reason)), + }), + }), + onNone: () => ({ + model: reflectLinkMenus( + modifyFields(model, { links: () => ChannelLinks.Failure({ error: reason }) }), + [], + ), + }), + }) + }, + ClickedRetryListChannelLinks: () => reloadLinks(model), UpdatedChannelNames: ({ names }) => ({ model: modifyFields(model, { channelNames: () => names }) }), ClickedBack: () => ({ model, outMessage: toList(shared), }), - ClickedDisconnect: () => openDisconnectModal(model), + ClickedDisconnect: () => ({ + model: withDisconnectConfirm( + model, + ConfirmAction.ask(model.disconnectConfirm, model.connectionId), + ), + }), ClickedConfirmDisconnect: () => - model.isDisconnecting - ? { model } - : { - model: modifyFields(model, { isDisconnecting: () => true }), - commands: [DisconnectConnection({ syncConnectionId: model.connectionId })], - }, + Option.match(ConfirmAction.confirm(model.disconnectConfirm), { + onNone: () => ({ model }), + onSome: ([running, syncConnectionId]) => ({ + model: withDisconnectConfirm(model, running), + commands: [DisconnectConnection({ syncConnectionId })], + }), + }), SucceededDisconnect: () => ({ - ...closeDisconnectModal(modifyFields(model, { isDisconnecting: () => false })), + model: withDisconnectConfirm(model, ConfirmAction.close(model.disconnectConfirm)), outMessage: toList(shared, successToast("Connection deleted")), }), - FailedDisconnect: ({ toast: request }) => ({ - ...closeDisconnectModal(modifyFields(model, { isDisconnecting: () => false })), - outMessage: toast(request), + FailedDisconnect: ({ reason }) => ({ + model: withDisconnectConfirm(model, ConfirmAction.failed(model.disconnectConfirm)), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Connection not found", + description: "This connection may have already been deleted.", + }, + }), + ), + }), }), ClickedLinkChannel: () => openAddLinkModal(model), GotAddLinkModalMessage: ({ message: modalMessage }) => foldAddLinkModal(model, modalMessage), @@ -362,44 +438,57 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedDirection: ({ direction }) => ({ model: modifyFields(model, { direction: () => direction }) }), ClickedCreateLink: () => submitLink(model), // `onSuccess()` reloads the links (a new query key), then `handleClose()`. - SucceededCreateLink: ({ successMessage }) => { - const closed = closeAddLinkModal(modifyFields(model, { isCreatingLink: () => false })) - return { - ...withCommands(reloadLinks(closed.model), closed.commands), - outMessage: toast(successToast(successMessage)), - } - }, - FailedCreateLink: ({ toast: request }) => ({ - model: modifyFields(model, { isCreatingLink: () => false }), - outMessage: toast(request), + SucceededCreateLink: ({ successMessage }) => ({ + ...Update.combine(modifyFields(model, { submission: () => Submission.NotSubmitted() }), [ + closeAddLinkModal, + reloadLinks, + ]), + outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), + }), + FailedCreateLink: ({ reason }) => ({ + model: modifyFields(model, { submission: () => Submission.Failed({ reason }) }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + ConnectionNotFound: { + title: "Connection not found", + description: "This sync connection may have been deleted.", + }, + AlreadyLinked: { + title: "Link already exists", + description: "This channel pair is already linked.", + }, + }), + ), + }), }), ClickedConfirmRemoveLink: () => - model.deleteTarget === null || model.isDeletingLink - ? { model } - : { - model: modifyFields(model, { isDeletingLink: () => true }), - commands: [RemoveChannelLink({ syncChannelLinkId: model.deleteTarget.id })], - }, - SucceededRemoveLink: () => { - const closed = closeDeleteLinkModal(modifyFields(model, { isDeletingLink: () => false })) - return { - ...withCommands(reloadLinks(closed.model), closed.commands), - outMessage: toast(successToast("Channel link removed")), - } - }, - FailedRemoveLink: ({ toast: request }) => ({ - model: modifyFields(model, { isDeletingLink: () => false }), - outMessage: toast(request), + Option.match(ConfirmAction.confirm(model.removeLinkConfirm), { + onNone: () => ({ model }), + onSome: ([running, target]) => ({ + model: withRemoveLinkConfirm(model, running), + commands: [RemoveChannelLink({ syncChannelLinkId: target.id })], + }), + }), + SucceededRemoveLink: () => ({ + ...reloadLinks(withRemoveLinkConfirm(model, ConfirmAction.close(model.removeLinkConfirm))), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Channel link removed") }), + }), + FailedRemoveLink: ({ reason }) => ({ + model: withRemoveLinkConfirm(model, ConfirmAction.failed(model.removeLinkConfirm)), + outMessage: linkFailureToast(reason), }), SucceededUpdateLink: ({ linkId, successMessage }) => ({ ...reloadLinks(settledLink(model, linkId)), - outMessage: toast(successToast(successMessage)), + outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), }), - FailedUpdateLink: ({ linkId, toast: request }) => ({ + FailedUpdateLink: ({ linkId, reason }) => ({ model: settledLink(model, linkId), - outMessage: toast(request), + outMessage: linkFailureToast(reason), }), GotLinkMenuMessage: ({ linkId, message: menuMessage }) => foldLinkMenu(model, linkId, menuMessage), - GotDeleteLinkModalMessage: ({ message: modalMessage }) => foldDeleteLinkModal(model, modalMessage), - GotDisconnectModalMessage: ({ message: modalMessage }) => foldDisconnectModal(model, modalMessage), + GotRemoveLinkConfirmMessage: ({ message: confirmMessage }) => + foldRemoveLinkConfirm(model, confirmMessage), + GotDisconnectConfirmMessage: ({ message: confirmMessage }) => + foldDisconnectConfirm(model, confirmMessage), }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/view.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/view.ts index 19c869884..a6e59b65e 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/view.ts @@ -1,13 +1,23 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" import { IconPlus } from "../../../icons" import { button } from "../../../ui/button" import { emptyState } from "../../../ui/empty-state" -import type * as Modal from "../../../ui/modal" import type { PageViewInputs } from "../../contract" +import type * as ConfirmAction from "../../shared/confirm-action" import { confirmDialog } from "../chat-sync/confirm-dialog" +import { isRetryable } from "../chat-sync/discord" import type { Connection } from "../chat-sync/model" -import { type ChannelLink, Message, type Model } from "./model" +import { + type ChannelLink, + type ListLinksFailure, + listedLinks, + listLinksFailureMessage, + Message, + type Model, +} from "./model" import { addLinkModal } from "./add-link-view" import { connectionCard, header } from "./connection-card" import { aboutCard, channelLinkRow, outlineIcon } from "./parts" @@ -19,8 +29,10 @@ const WARNING_PATH = const LINK_PATH = "M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" -const toDeleteLinkModalMessage = (message: Modal.Message) => Message.GotDeleteLinkModalMessage({ message }) -const toDisconnectModalMessage = (message: Modal.Message) => Message.GotDisconnectModalMessage({ message }) +const toRemoveLinkConfirmMessage = (message: ConfirmAction.Message) => + Message.GotRemoveLinkConfirmMessage({ message }) +const toDisconnectConfirmMessage = (message: ConfirmAction.Message) => + Message.GotDisconnectConfirmMessage({ message }) const backLink = (h: HtmlBuilder<Message>): Html => h.button( @@ -91,8 +103,33 @@ const linksBody = (h: HtmlBuilder<Message>, model: Model, links: ReadonlyArray<C ), ) +/** A first links list that failed: what went wrong, with "Try again" when it may pass. */ +const linksFailed = (h: HtmlBuilder<Message>, reason: ListLinksFailure): Html => { + const message = listLinksFailureMessage(reason) + return h.div( + [h.Class("flex flex-col items-center justify-center px-5 py-12 text-center")], + [ + h.p([h.Class("mb-1 font-medium text-fg text-sm")], ["Failed to load channel links"]), + h.p([h.Class("mb-4 text-muted-fg text-sm")], [message.description ?? message.title]), + ...(isRetryable(reason) + ? [ + button( + h, + { + intent: "secondary", + size: "sm", + onPress: Message.ClickedRetryListChannelLinks(), + }, + ["Try again"], + ), + ] + : []), + ], + ) +} + const linksCard = (h: HtmlBuilder<Message>, model: Model): Html => { - const links = model.links._tag === "Loaded" ? model.links.links : [] + const links = listedLinks(model) return h.div( [h.Class("overflow-hidden rounded-xl border border-border bg-bg")], [ @@ -124,9 +161,14 @@ const linksCard = (h: HtmlBuilder<Message>, model: Model): Html => { linkChannelButton(h, "secondary"), ], ), - model.links._tag === "Loading" - ? spinner(h, "size-5", "Loading channel links...", "p-8") - : linksBody(h, model, links), + AsyncData.match(model.links, { + onIdle: () => spinner(h, "size-5", "Loading channel links...", "p-8"), + onLoading: () => spinner(h, "size-5", "Loading channel links...", "p-8"), + onFailure: (reason) => linksFailed(h, reason), + onRefreshing: (data) => linksBody(h, model, data), + onStale: ({ data }) => linksBody(h, model, data), + onSuccess: (data) => linksBody(h, model, data), + }), ], ) } @@ -153,53 +195,87 @@ const detail = (h: HtmlBuilder<Message>, model: Model, connection: Connection): addLinkModal(h, model), confirmDialog(h, { slotId: "remove-link", - modal: model.deleteLinkModal, + confirm: model.removeLinkConfirm, icon: warningIcon(h), title: "Remove Channel Link", - description: [ + description: (target) => [ "Are you sure you want to remove the link to", - model.deleteTarget?.name ?? "", + target.name, "? Messages will stop syncing between these channels.", ], confirmLabel: "Remove Link", pendingLabel: "Removing...", - isPending: model.isDeletingLink, onConfirm: Message.ClickedConfirmRemoveLink(), - toParentMessage: toDeleteLinkModalMessage, + toParentMessage: toRemoveLinkConfirmMessage, }), confirmDialog(h, { slotId: "disconnect", - modal: model.disconnectModal, + confirm: model.disconnectConfirm, icon: warningIcon(h), title: "Disconnect from Discord", - description: [ + description: () => [ "Are you sure you want to disconnect", connection.displayName, "? All channel links will be removed and messages will stop syncing. This action cannot be undone.", ], confirmLabel: "Disconnect", pendingLabel: "Disconnecting...", - isPending: model.isDisconnecting, onConfirm: Message.ClickedConfirmDisconnect(), - toParentMessage: toDisconnectModalMessage, + toParentMessage: toDisconnectConfirmMessage, + }), + ], + ) + +const goBack = (h: HtmlBuilder<Message>): Html => + button(h, { intent: "secondary", onPress: Message.ClickedBack() }, ["Go back"]) + +const notFound = (h: HtmlBuilder<Message>): Html => + h.div( + [h.Class("flex flex-col gap-6")], + [ + backLink(h), + emptyState(h, { + title: "Connection not found", + description: "This sync connection may have been deleted.", + action: goBack(h), }), ], ) -export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => { - if (model.connection._tag === "Loading") return spinner(h, "size-6", "Loading connection...", "py-24") - const connection = model.connection.connection - if (connection === null) - return h.div( - [h.Class("flex flex-col gap-6")], - [ - backLink(h), - emptyState(h, { - title: "Connection not found", - description: "This sync connection may have been deleted.", - action: button(h, { intent: "secondary", onPress: Message.ClickedBack() }, ["Go back"]), - }), - ], - ) - return detail(h, model, connection) -}) +/** The connection list failed: say why and offer "Try again" when it may pass, never "not found". */ +const loadFailed = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return h.div( + [h.Class("flex flex-col gap-6")], + [ + backLink(h), + emptyState(h, { + title: "Failed to load connection", + description: message.description ?? message.title, + action: isRetryableReason(reason) + ? [ + button(h, { intent: "primary", onPress: Message.ClickedRetryLoadConnection() }, [ + "Try again", + ]), + goBack(h), + ] + : goBack(h), + }), + ], + ) +} + +const found = (h: HtmlBuilder<Message>, model: Model, connection: Connection | null): Html => + connection === null ? notFound(h) : detail(h, model, connection) + +export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => + AsyncData.match(model.connection, { + onIdle: () => spinner(h, "size-6", "Loading connection...", "py-24"), + onLoading: () => spinner(h, "size-6", "Loading connection...", "py-24"), + onFailure: (reason) => loadFailed(h, reason), + // A refresh (or a failed one, which toasts) keeps the shown connection. + onRefreshing: (connection) => found(h, model, connection), + onStale: ({ data }) => found(h, model, data), + onSuccess: (connection) => found(h, model, connection), + }), +) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/add-connection-view.ts b/apps/web-foldkit/src/page/settings/chat-sync/add-connection-view.ts index 76908320e..a1d88759c 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/add-connection-view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/add-connection-view.ts @@ -1,3 +1,4 @@ +import * as AsyncData from "foldkit/asyncData" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" import { dialogBody, dialogClose, dialogFooter, dialogHeader, dialogTitle } from "../../../ui/dialog" @@ -5,7 +6,7 @@ import { description, label } from "../../../ui/field" import * as Modal from "../../../ui/modal" import { GUILD_SEARCH_ID } from "./add-connection" import { discordLogo } from "./brand-icons" -import type { DiscordGuild } from "./discord" +import { type DiscordFailure, type DiscordGuild, discordFailureMessage, isRetryable } from "./discord" import { filterByName, scrollList, searchInput, selectedName, selectedRow, spinnerLine } from "./modal-parts" import { Message, type Model } from "./model" import { CONNECT_TARGET, GUILD_SEARCH_TARGET, interaction } from "./update" @@ -95,50 +96,86 @@ const connectFirst = (h: HtmlBuilder<Message>): Html => ], ) -const body = (h: HtmlBuilder<Message>, model: Model): Html => { - const guilds = model.discordGuilds - if (guilds._tag === "Loading") - return h.div( - [h.Class("flex items-center justify-center py-8")], - [spinnerLine(h, "Loading Discord guilds...")], - ) - return guilds._tag === "Failed" ? connectFirst(h) : guildPicker(h, model, guilds.items) +/** A guild list that failed for another reason than Discord not being authorized. */ +const loadFailed = (h: HtmlBuilder<Message>, reason: DiscordFailure): Html => { + const message = discordFailureMessage(reason, "guilds") + return h.div( + [h.Class("rounded-lg border border-border bg-bg-muted/20 p-4")], + [ + h.p([h.Class("font-medium text-fg text-sm")], [message.title]), + ...(message.description === undefined + ? [] + : [h.p([h.Class("mt-1 text-muted-fg text-sm")], [message.description])]), + ...(isRetryable(reason) + ? [ + button( + h, + { + intent: "secondary", + size: "sm", + className: "mt-3", + onPress: Message.ClickedRetryListDiscordGuilds(), + }, + ["Try again"], + ), + ] + : []), + ], + ) } +const loadingGuilds = (h: HtmlBuilder<Message>): Html => + h.div([h.Class("flex items-center justify-center py-8")], [spinnerLine(h, "Loading Discord guilds...")]) + +const body = (h: HtmlBuilder<Message>, model: Model): Html => + AsyncData.match(model.discordGuilds, { + onIdle: () => loadingGuilds(h), + onLoading: () => loadingGuilds(h), + onFailure: (reason) => (reason === "NotConnected" ? connectFirst(h) : loadFailed(h, reason)), + onRefreshing: (guilds) => guildPicker(h, model, guilds), + onStale: ({ data }) => guildPicker(h, model, data), + onSuccess: (guilds) => guildPicker(h, model, guilds), + }) + const content = ( h: HtmlBuilder<Message>, model: Model, closeAttributes: ReadonlyArray<ChildAttribute>, -): ReadonlyArray<Html> => [ - dialogHeader(h, {}, [ - h.div( - [h.Class("flex items-center gap-3")], - [ - h.div( - [h.Class("flex size-10 items-center justify-center rounded-xl bg-[#5865F2]/[0.06]")], - [discordLogo(h, "size-6")], - ), - dialogTitle(h, { id: Modal.titleId(model.addModal.id) }, "Connect Discord Server"), - ], - ), - ]), - dialogBody(h, [body(h, model)], "flex flex-col gap-5"), - dialogFooter(h, [ - dialogClose(h, closeAttributes, ["Cancel"], "secondary"), - button( - h, - { - intent: "primary", - onPress: Message.ClickedConnect(), - isDisabled: - model.selectedGuild === null || model.isCreating || model.discordGuilds._tag !== "Loaded", - isPending: model.isCreating, - interaction: { wiring: interaction.wiring(model), target: CONNECT_TARGET }, - }, - [model.isCreating ? "Connecting..." : "Connect"], - ), - ]), -] +): ReadonlyArray<Html> => { + const isSubmitting = model.submission._tag === "Submitting" + return [ + dialogHeader(h, {}, [ + h.div( + [h.Class("flex items-center gap-3")], + [ + h.div( + [h.Class("flex size-10 items-center justify-center rounded-xl bg-[#5865F2]/[0.06]")], + [discordLogo(h, "size-6")], + ), + dialogTitle(h, { id: Modal.titleId(model.addModal.id) }, "Connect Discord Server"), + ], + ), + ]), + dialogBody(h, [body(h, model)], "flex flex-col gap-5"), + dialogFooter(h, [ + dialogClose(h, closeAttributes, ["Cancel"], "secondary"), + button( + h, + { + intent: "primary", + onPress: Message.ClickedConnect(), + isDisabled: + model.selectedGuild === null || + isSubmitting || + !AsyncData.isSuccess(model.discordGuilds), + isPending: isSubmitting, + interaction: { wiring: interaction.wiring(model), target: CONNECT_TARGET }, + }, + [isSubmitting ? "Connecting..." : "Connect"], + ), + ]), + ] +} /** `<Modal><ModalContent isOpen onOpenChange size="md">`, rendered while open. */ export const addConnectionModal = (h: HtmlBuilder<Message>, model: Model): Html => diff --git a/apps/web-foldkit/src/page/settings/chat-sync/add-connection.ts b/apps/web-foldkit/src/page/settings/chat-sync/add-connection.ts index c99d598f2..3eb79a750 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/add-connection.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/add-connection.ts @@ -1,9 +1,9 @@ import { OrganizationId } from "@hazel/schema" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command, Render } from "foldkit" import * as Dom from "foldkit/dom" import { HazelRpc } from "../../../rpc" -import { failureToast } from "../../../data/actions" +import { catchCommon } from "../../../data/actions" import { Message } from "./model" /** `AddConnectionModal`'s commands (`components/chat-sync/add-connection-modal.tsx`). */ @@ -11,7 +11,7 @@ import { Message } from "./model" export const ADD_CONNECTION_MODAL_ID = "chat-sync-add-connection" export const GUILD_SEARCH_ID = `${ADD_CONNECTION_MODAL_ID}-search` -/** `createConnection` (`chatSync.connection.create`) with the modal's `exitToast` handlers. */ +/** `createConnection` (`chatSync.connection.create`). */ export const CreateConnection = Command.define("CreateConnection", { args: { organizationId: OrganizationId, @@ -24,33 +24,22 @@ export const CreateConnection = Command.define("CreateConnection", { // React paints the pending button on the click, before any reply; wait for that commit. yield* Render.afterCommit const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("chatSync.connection.create", { - organizationId, - provider: "discord", - externalWorkspaceId, - externalWorkspaceName, - }), - ) - return Exit.match(exit, { - onSuccess: () => Message.SucceededCreateConnection(), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast", { - ChatSyncConnectionExistsError: { - title: "Connection already exists", - description: "A connection to this Discord server already exists in your workspace.", - isRetryable: false, - }, - ChatSyncIntegrationNotConnectedError: { - title: "Discord not connected", - description: "Connect Discord first to load your guilds.", - isRetryable: false, - }, - }) - return Message.FailedCreateConnection({ toast }) - }, + yield* client("chatSync.connection.create", { + organizationId, + provider: "discord", + externalWorkspaceId, + externalWorkspaceName, }) - }), + return Message.SucceededCreateConnection() + }).pipe( + Effect.catchTags({ + ChatSyncConnectionExistsError: () => + Effect.succeed(Message.FailedCreateConnection({ reason: "AlreadyExists" })), + ChatSyncIntegrationNotConnectedError: () => + Effect.succeed(Message.FailedCreateConnection({ reason: "DiscordNotConnected" })), + }), + catchCommon((reason) => Message.FailedCreateConnection({ reason })), + ), }) /** The search Input's `autoFocus`, once it is rendered. */ diff --git a/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts b/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts index c41e8da08..a138c4b67 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts @@ -1,34 +1,39 @@ +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { dialogDescriptionBase } from "~/components/ui/dialog.styles" import { button } from "../../../ui/button" import { dialog, dialogClose, dialogFooter, dialogHeader, dialogTitle } from "../../../ui/dialog" import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" /** * The chat sync pages' confirmations: `<Modal><ModalContent isOpen size="md"><Dialog>` with a danger * icon tile, a title, a description naming the target, Cancel and a pending danger button. */ -export const confirmDialog = <Message>( +export const confirmDialog = <Target, Message>( h: HtmlBuilder<Message>, options: Readonly<{ slotId: string - modal: Modal.Model + confirm: ConfirmAction.Model<Target> icon: Html title: string - /** Description text before and after the highlighted name. */ - description: readonly [string, string, string] + /** Description text before and after the highlighted name of the target. */ + description: (target: Target) => readonly [string, string, string] confirmLabel: string pendingLabel: string - isPending: boolean onConfirm: Message - toParentMessage: (message: Modal.Message) => Message + toParentMessage: (message: ConfirmAction.Message) => Message }>, ): Html => { const titleId = `${options.slotId}-title` - const [before, name, after] = options.description + const isPending = ConfirmAction.isRunning(options.confirm) + const [before, name, after] = Option.match(ConfirmAction.targetOf(options.confirm), { + onNone: () => ["", "", ""] as const, + onSome: options.description, + }) return h.submodel({ slotId: options.slotId, - model: options.modal, + model: options.confirm.modal, view: Modal.view, viewInputs: { // Controlled `isOpen` with no DialogTrigger. @@ -57,16 +62,16 @@ export const confirmDialog = <Message>( h, { intent: "danger", - isDisabled: options.isPending, - isPending: options.isPending, + isDisabled: isPending, + isPending, onPress: options.onConfirm, }, - [options.isPending ? options.pendingLabel : options.confirmLabel], + [isPending ? options.pendingLabel : options.confirmLabel], ), ]), ]), ], }, - toParentMessage: options.toParentMessage, + toParentMessage: (message) => options.toParentMessage(ConfirmAction.toModalMessage(message)), }) } diff --git a/apps/web-foldkit/src/page/settings/chat-sync/discord.ts b/apps/web-foldkit/src/page/settings/chat-sync/discord.ts index 55565e140..79d5ec9da 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/discord.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/discord.ts @@ -1,5 +1,7 @@ import { ExternalChannelId, type OrganizationId } from "@hazel/schema" import { Effect, Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" +import { CommonReason, isRetryableReason, type UserErrorMessage, failureMessage } from "~/lib/error-messages" import { HazelApiClient } from "../../../rpc" /** A Discord guild as `AddConnectionModal` lists it. */ @@ -21,13 +23,15 @@ export const DiscordChannel = Schema.Struct({ }) export type DiscordChannel = typeof DiscordChannel.Type -/** An `HazelApiClient.query` result as AsyncResult: initial, failure or success. */ -export const DiscordResource = <A extends Schema.Top>(item: A) => - Schema.Union([ - Schema.TaggedStruct("Loading", {}), - Schema.TaggedStruct("Failed", {}), - Schema.TaggedStruct("Loaded", { items: Schema.Array(item) }), - ]) +/** + * Why a Discord resource list failed: Discord is not authorized for the organization, Discord itself + * refused (e.g. the bot lacks access), or a common reason. + */ +export const DiscordFailure = Schema.Union([Schema.Literals(["NotConnected", "ProviderError"]), CommonReason]) +export type DiscordFailure = typeof DiscordFailure.Type + +export const DiscordGuilds = AsyncData.Schema(Schema.Array(DiscordGuild), DiscordFailure) +export const DiscordChannels = AsyncData.Schema(Schema.Array(DiscordChannel), DiscordFailure) /** `HazelApiClient.query("integration-resources", "getDiscordGuilds", ...)` */ export const fetchDiscordGuilds = (orgId: OrganizationId) => @@ -40,3 +44,21 @@ export const fetchDiscordGuildChannels = (orgId: OrganizationId, guildId: string HazelApiClient.use((client) => client["integration-resources"].getDiscordGuildChannels({ params: { orgId, guildId } }), ).pipe(Effect.map((response): ReadonlyArray<DiscordChannel> => response.channels)) + +const isCommonReason = Schema.is(CommonReason) + +/** Whether a failure is transient, so the view offers "Try again". */ +export const isRetryable = (reason: string): boolean => isCommonReason(reason) && isRetryableReason(reason) + +/** The inline copy for a Discord list (`resource`: "guilds" or "channels") that failed to load. */ +export const discordFailureMessage = (reason: DiscordFailure, resource: string): UserErrorMessage => + failureMessage(reason, { + NotConnected: { + title: "Connect Discord first", + description: `Authorize Discord in Integrations so we can load your ${resource}.`, + }, + ProviderError: { + title: `Could not load Discord ${resource}`, + description: "Make sure the bot is installed in this server and has channel access.", + }, + }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/model.ts b/apps/web-foldkit/src/page/settings/chat-sync/model.ts index 9149c0661..90094ae88 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/model.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/model.ts @@ -1,11 +1,14 @@ import { OrganizationId, SyncConnectionId } from "@hazel/schema" import { Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" import * as Menu from "../../../ui/menu" import * as Modal from "../../../ui/modal" -import { DiscordGuild, DiscordResource } from "./discord" +import * as ConfirmAction from "../../shared/confirm-action" +import { DiscordFailure, DiscordGuild, DiscordGuilds } from "./discord" export const ConnectionStatus = Schema.Literals(["active", "paused", "error", "disabled"]) export type ConnectionStatus = typeof ConnectionStatus.Type @@ -21,34 +24,45 @@ export const Connection = Schema.Struct({ }) export type Connection = typeof Connection.Type -/** `HazelRpcClient.query("chatSync.connection.list")` as AsyncResult: initial, failure or success. */ -export const Connections = Schema.Union([ - Schema.TaggedStruct("Loading", {}), - Schema.TaggedStruct("Failed", {}), - Schema.TaggedStruct("Loaded", { connections: Schema.Array(Connection) }), -]) -export type Connections = typeof Connections.Type +/** `chatSync.connection.list`: its only declared errors are the common ones. */ +export const Connections = AsyncData.Schema(Schema.Array(Connection), CommonReason) export const DeleteTarget = Schema.Struct({ id: SyncConnectionId, name: Schema.String }) +export type DeleteTarget = typeof DeleteTarget.Type + +export const DeleteConnectionFailure = Schema.Union([Schema.Literal("NotFound"), CommonReason]) +export type DeleteConnectionFailure = typeof DeleteConnectionFailure.Type + +export const CreateConnectionFailure = Schema.Union([ + Schema.Literals(["AlreadyExists", "DiscordNotConnected"]), + CommonReason, +]) +export type CreateConnectionFailure = typeof CreateConnectionFailure.Type + +/** The add modal's Connect: not pressed yet, creating, or failed (the modal stays open to retry). */ +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Submitting: {}, + Failed: { reason: CreateConnectionFailure }, +}) +export type Submission = typeof Submission.Type export const Model = Schema.Struct({ /** The organization the list was requested for; the query reruns when it changes. */ requestedOrganizationId: Schema.NullOr(OrganizationId), - connections: Connections, + connections: Connections.schema, addMenu: Menu.Model, emptyAddMenu: Menu.Model, - /** `AddConnectionModal`: open state, its guild query (run whenever the modal mounts) and form. */ + /** `AddConnectionModal`: open state, its guild query (run when the first list mounts it) and form. */ addModal: Modal.Model, - discordGuilds: DiscordResource(DiscordGuild), + discordGuilds: DiscordGuilds.schema, /** Bumped per guild request; a list for an older request or another organization is dropped. */ guildsVersion: Schema.Number, selectedGuild: Schema.NullOr(DiscordGuild), guildSearch: Schema.String, isGuildSearchFocused: Schema.Boolean, - isCreating: Schema.Boolean, - deleteTarget: Schema.NullOr(DeleteTarget), - deleteModal: Modal.Model, - isDeleting: Schema.Boolean, + submission: Submission, + deleteConfirm: ConfirmAction.Model(DeleteTarget), /** Hover, press and focus of the add modal's search Input and Connect button. */ interaction: Interaction.Model, }) @@ -56,21 +70,27 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ SucceededListConnections: { organizationId: OrganizationId, connections: Schema.Array(Connection) }, - FailedListConnections: { organizationId: OrganizationId }, + FailedListConnections: { organizationId: OrganizationId, reason: CommonReason }, + ClickedRetryListConnections: {}, SucceededListDiscordGuilds: { organizationId: OrganizationId, version: Schema.Number, guilds: Schema.Array(DiscordGuild), }, - FailedListDiscordGuilds: { organizationId: OrganizationId, version: Schema.Number }, + FailedListDiscordGuilds: { + organizationId: OrganizationId, + version: Schema.Number, + reason: DiscordFailure, + }, + ClickedRetryListDiscordGuilds: {}, ClickedConnection: { connectionId: SyncConnectionId }, ClickedDeleteConnection: { target: DeleteTarget }, ClickedConfirmDelete: {}, SucceededDeleteConnection: {}, - FailedDeleteConnection: { toast: ToastRequest }, + FailedDeleteConnection: { reason: DeleteConnectionFailure }, GotAddMenuMessage: { message: Menu.Message }, GotEmptyAddMenuMessage: { message: Menu.Message }, - GotDeleteModalMessage: { message: Modal.Message }, + GotDeleteConfirmMessage: { message: ConfirmAction.Message }, GotAddModalMessage: { message: Modal.Message }, ChangedGuildSearch: { value: Schema.String }, FocusedGuildSearch: {}, @@ -81,7 +101,7 @@ export const Message = defineMessageUnion({ ClickedOpenDiscordIntegration: {}, ClickedConnect: {}, SucceededCreateConnection: {}, - FailedCreateConnection: { toast: ToastRequest }, + FailedCreateConnection: { reason: CreateConnectionFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts index 24715d5e6..97270a257 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { errorToast, successToast } from "../../../data/actions" +import { successToast } from "../../../data/actions" import * as Menu from "../../../ui/menu" import { FocusTriggerOnPress } from "../../../ui/menu-view" import * as Modal from "../../../ui/modal" @@ -9,6 +9,7 @@ import { makeShared, organizationId, pageScene, portalModalMounted } from "../.. import { connection, guild, otherGuild, syncConnectionId } from "../../../test/pages-integrations-fixtures" import { PageOutMessage } from "../../out-message" import { CreateConnection, FocusGuildSearch } from "./add-connection" +import { DiscordGuilds } from "./discord" import { Message, type Model } from "./model" import { DeleteConnection, init, ListConnections, ListDiscordGuilds, update } from "./update" import { view } from "./view" @@ -34,6 +35,7 @@ const dialog = Scene.role("dialog") const confirmDelete = Scene.within(dialog, Scene.role("button", { name: "Delete Connection" })) const connect = Scene.within(dialog, Scene.role("button", { name: "Connect" })) const search = Scene.placeholder("Search servers...") +const tryAgain = Scene.role("button", { name: "Try again" }) /** The add menu trigger focuses itself on press; the add modal is a controlled (non-submodel) modal. */ const addTriggerMounted = Scene.Mount.resolve( FocusTriggerOnPress, @@ -54,12 +56,38 @@ describe("list states", () => { ) }) - test("a failed list explains the failure", () => { + test("a failed list explains why, not 'no connections', and Try again reloads it", () => { Scene.scene( config, - Scene.given(step(initial, Message.FailedListConnections({ organizationId }))), + Scene.given(step(initial, Message.FailedListConnections({ organizationId, reason: "Offline" }))), Scene.expect(Scene.text("Failed to load connections")).toExist(), - Scene.expect(Scene.text("Loading connections...")).toBeAbsent(), + Scene.expect(Scene.text("Check your internet connection and try again.")).toExist(), + Scene.expect(Scene.text("No sync connections yet")).toBeAbsent(), + Scene.click(tryAgain), + Scene.Command.expectExact(ListConnections({ organizationId })), + Scene.expect(Scene.text("Loading connections...")).toExist(), + Scene.Command.resolve( + ListConnections, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ), + Scene.Command.resolve( + ListDiscordGuilds, + Message.SucceededListDiscordGuilds({ organizationId, version: 1, guilds: [guild] }), + ), + addTriggerMounted, + Scene.expect(Scene.text("Hazel Community")).toExist(), + ) + }) + + test("a failure a retry won't fix offers no Try again", () => { + Scene.scene( + config, + Scene.given( + step(initial, Message.FailedListConnections({ organizationId, reason: "SignedOut" })), + ), + Scene.expect(Scene.text("Failed to load connections")).toExist(), + Scene.expect(Scene.text("Please sign in again to continue.")).toExist(), + Scene.expect(tryAgain).toBeAbsent(), ) }) @@ -92,7 +120,7 @@ describe("list states", () => { }) describe("delete a connection", () => { - test("confirming deletes, toasts, closes the dialog and reloads the list", () => { + test("confirming deletes, toasts, closes the dialog and refreshes the shown list", () => { Scene.scene( config, Scene.given(loaded), @@ -109,23 +137,19 @@ describe("delete a connection", () => { ), Scene.expect(dialog).toBeAbsent(), Scene.Mount.expectEnded(Modal.PortalModal), - Scene.expect(Scene.text("Loading connections...")).toExist(), - Scene.Mount.expectEnded(FocusTriggerOnPress), + // The list stays while it refreshes. + Scene.expect(Scene.text("Loading connections...")).toBeAbsent(), + Scene.expect(Scene.text("Hazel Community")).toExist(), Scene.Command.resolve( ListConnections, Message.SucceededListConnections({ organizationId, connections: [] }), ), - Scene.Command.resolve( - ListDiscordGuilds, - Message.FailedListDiscordGuilds({ organizationId, version: 2 }), - ), Scene.expect(Scene.text("No sync connections yet")).toExist(), addTriggerMounted, - addTriggerMounted, ) }) - test("a failed delete toasts the error and re-enables the confirm button", () => { + test("a failed delete toasts the reason and re-enables the confirm button", () => { Scene.scene( config, Scene.given(loaded), @@ -133,13 +157,14 @@ describe("delete a connection", () => { Scene.click(deleteTrigger), portalModalMounted, Scene.click(confirmDelete), - Scene.Command.resolve( - DeleteConnection, - Message.FailedDeleteConnection({ toast: errorToast("Connection not found") }), - ), + Scene.Command.resolve(DeleteConnection, Message.FailedDeleteConnection({ reason: "NotFound" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Connection not found", description: null }, + toast: { + intent: "error", + title: "Connection not found", + description: "This connection may have already been deleted.", + }, }), ), Scene.expect(confirmDelete).toBeEnabled(), @@ -192,16 +217,10 @@ describe("connect a Discord server", () => { ), Scene.expect(dialog).toBeAbsent(), Scene.Mount.expectEnded(Modal.PortalModal), - Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.Command.resolve( ListConnections, Message.SucceededListConnections({ organizationId, connections: [connection] }), ), - Scene.Command.resolve( - ListDiscordGuilds, - Message.SucceededListDiscordGuilds({ organizationId, version: 2, guilds: [guild] }), - ), - addTriggerMounted, Scene.expect(Scene.text("Hazel Community")).toExist(), ) }) @@ -215,11 +234,15 @@ describe("connect a Discord server", () => { Scene.click(connect), Scene.Command.resolve( CreateConnection, - Message.FailedCreateConnection({ toast: errorToast("Connection already exists") }), + Message.FailedCreateConnection({ reason: "AlreadyExists" }), ), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Connection already exists", description: null }, + toast: { + intent: "error", + title: "Connection already exists", + description: "A connection to this Discord server already exists in your workspace.", + }, }), ), Scene.expect(Scene.within(dialog, Scene.text("Design Systems Guild"))).toExist(), @@ -230,10 +253,14 @@ describe("connect a Discord server", () => { test("without Discord authorized, the modal links to the Discord integration", () => { Scene.scene( config, - Scene.given({ ...withAddModalOpen, discordGuilds: { _tag: "Failed" } }), + Scene.given({ + ...withAddModalOpen, + discordGuilds: DiscordGuilds.Failure({ error: "NotConnected" }), + }), addTriggerMounted, addModalMounted, Scene.expect(connect).toBeDisabled(), + Scene.expect(Scene.within(dialog, tryAgain)).toBeAbsent(), Scene.click(Scene.role("button", { name: "Open Discord Integration" })), Scene.expectOutMessage( PageOutMessage.RequestedNavigation({ @@ -243,4 +270,26 @@ describe("connect a Discord server", () => { ), ) }) + + test("guilds that failed to load say why, and Try again queries them again", () => { + Scene.scene( + config, + Scene.given({ ...withAddModalOpen, discordGuilds: DiscordGuilds.Failure({ error: "Offline" }) }), + addTriggerMounted, + addModalMounted, + Scene.expect(Scene.within(dialog, Scene.text("Connection lost"))).toExist(), + Scene.expect(Scene.text("Open Discord Integration")).toBeAbsent(), + Scene.click(Scene.within(dialog, tryAgain)), + Scene.Command.expectExact(ListDiscordGuilds({ organizationId, version: 2 })), + Scene.expect(Scene.within(dialog, Scene.text("Loading Discord guilds..."))).toExist(), + Scene.Command.resolve( + ListDiscordGuilds, + Message.SucceededListDiscordGuilds({ organizationId, version: 2, guilds: [guild] }), + ), + Scene.Command.resolve(FocusGuildSearch, Message.CompletedFocusGuildSearch()), + Scene.expect( + Scene.within(dialog, Scene.role("button", { name: /Design Systems Guild/ })), + ).toExist(), + ) + }) }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts index 86a65f20e..d5f55aa85 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/story.test.ts @@ -1,15 +1,16 @@ -import "../../channel-settings/document-stub" +import "../../../test/document-stub" import { OrganizationId, SyncConnectionId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { Message } from "./model" +import * as ConfirmAction from "../../shared/confirm-action" +import { Connections, Message, Submission } from "./model" import { DeleteConnection, init, ListConnections, ListDiscordGuilds, sharedChanged, update } from "./update" import { sharedDefaults } from "../../test-shared" import { CreateConnection } from "./add-connection" -import { errorToast } from "../../../data/actions" +import { DiscordGuilds } from "./discord" const organizationId = Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-000000000001") const connectionId = Schema.decodeSync(SyncConnectionId)("00000000-0000-4000-8000-000000000002") @@ -30,6 +31,7 @@ const connection = { errorMessage: null, lastSyncedAtMs: null, } +const target = { id: connectionId, name: "Hazel Community" } const updateWithShared = (current: Parameters<typeof update>[0], next: Message) => update(current, next, shared) @@ -41,7 +43,7 @@ describe("chat sync connections", () => { expect(ready.commands?.map((command) => command.name)).toEqual(["ListConnections"]) }) - test("deleting closes the dialog, toasts, and reloads the list", () => { + test("deleting closes the dialog, toasts, and refreshes the list while keeping it shown", () => { const loaded = updateWithShared( init(undefined, shared).model, Message.SucceededListConnections({ organizationId, connections: [connection] }), @@ -49,11 +51,10 @@ describe("chat sync connections", () => { story( updateWithShared, given(loaded), - message( - Message.ClickedDeleteConnection({ target: { id: connectionId, name: "Hazel Community" } }), - ), - model((current) => expect(current.deleteModal.isOpen).toBe(true)), + message(Message.ClickedDeleteConnection({ target })), + model((current) => expect(current.deleteConfirm.modal.isOpen).toBe(true)), message(Message.ClickedConfirmDelete()), + model((current) => expect(ConfirmAction.isRunning(current.deleteConfirm)).toBe(true)), Command.resolve(DeleteConnection, Message.SucceededDeleteConnection()), expectOutMessage( PageOutMessage.RequestedToast({ @@ -61,19 +62,17 @@ describe("chat sync connections", () => { }), ), model((current) => { - expect(current.deleteTarget).toBeNull() - expect(current.connections._tag).toBe("Loading") + expect(current.deleteConfirm.state._tag).toBe("Closed") + expect(current.deleteConfirm.modal.isOpen).toBe(false) + expect(current.connections).toEqual(Connections.Refreshing({ data: [connection] })) }), + // The add modal stays mounted through a refresh, so the guilds are not queried again. Command.resolve( ListConnections, Message.SucceededListConnections({ organizationId, connections: [] }), ), - // The reloaded list remounts the add modal, so its guild query runs again. - Command.resolve( - ListDiscordGuilds, - Message.FailedListDiscordGuilds({ organizationId, version: 2 }), - ), - model((current) => expect(current.connections).toEqual({ _tag: "Loaded", connections: [] })), + Command.expectNone(), + model((current) => expect(current.connections).toEqual(Connections.Success({ data: [] }))), ) }) @@ -88,7 +87,9 @@ describe("chat sync connections", () => { ListDiscordGuilds, Message.SucceededListDiscordGuilds({ organizationId, version: 1, guilds: [guild] }), ), - model((current) => expect(current.discordGuilds).toEqual({ _tag: "Loaded", items: [guild] })), + model((current) => + expect(current.discordGuilds).toEqual(DiscordGuilds.Success({ data: [guild] })), + ), ) }) @@ -103,23 +104,83 @@ describe("chat sync connections", () => { refetching, Message.SucceededListDiscordGuilds({ organizationId, version: 1, guilds: [guild] }), ) - expect(stale.model.discordGuilds).toEqual({ _tag: "Loading" }) + expect(stale.model.discordGuilds).toEqual(DiscordGuilds.Loading()) const staleFailure = updateWithShared( refetching, - Message.FailedListDiscordGuilds({ organizationId, version: 1 }), + Message.FailedListDiscordGuilds({ organizationId, version: 1, reason: "Offline" }), ) - expect(staleFailure.model.discordGuilds).toEqual({ _tag: "Loading" }) + expect(staleFailure.model.discordGuilds).toEqual(DiscordGuilds.Loading()) }) - test("a failed list renders no modal and sends no guild query", () => { - const failed = updateWithShared( + test("a failed guild list keeps its reason, and Try again queries the guilds anew", () => { + const listing = updateWithShared( init(undefined, shared).model, - Message.FailedListConnections({ organizationId }), + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ).model + story( + updateWithShared, + given(listing), + message(Message.FailedListDiscordGuilds({ organizationId, version: 1, reason: "Offline" })), + model((current) => + expect(current.discordGuilds).toEqual(DiscordGuilds.Failure({ error: "Offline" })), + ), + message(Message.ClickedRetryListDiscordGuilds()), + Command.expectExact(ListDiscordGuilds({ organizationId, version: 2 })), + model((current) => expect(current.discordGuilds).toEqual(DiscordGuilds.Loading())), + Command.resolve( + ListDiscordGuilds, + Message.SucceededListDiscordGuilds({ organizationId, version: 2, guilds: [] }), + ), ) - expect(failed.commands ?? []).toHaveLength(0) }) - test("connecting a picked guild closes the modal, toasts, and reloads the list", () => { + test("a failed first list keeps its reason, sends no guild query, and Try again reloads", () => { + story( + updateWithShared, + given(init(undefined, shared).model), + message(Message.FailedListConnections({ organizationId, reason: "Offline" })), + Command.expectNone(), + model((current) => + expect(current.connections).toEqual(Connections.Failure({ error: "Offline" })), + ), + message(Message.ClickedRetryListConnections()), + Command.expectExact(ListConnections({ organizationId })), + model((current) => expect(current.connections).toEqual(Connections.Loading())), + Command.resolve( + ListConnections, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ), + // The list mounts the add modal now, so the guilds are queried. + Command.expectHas(ListDiscordGuilds), + Command.resolve( + ListDiscordGuilds, + Message.SucceededListDiscordGuilds({ organizationId, version: 1, guilds: [] }), + ), + ) + }) + + test("a failed refresh keeps the shown list and toasts", () => { + const loaded = updateWithShared( + init(undefined, shared).model, + Message.SucceededListConnections({ organizationId, connections: [connection] }), + ).model + const failed = updateWithShared( + { ...loaded, connections: Connections.Refreshing({ data: [connection] }) }, + Message.FailedListConnections({ organizationId, reason: "Timeout" }), + ) + expect(failed.model.connections).toEqual(Connections.Stale({ error: "Timeout", data: [connection] })) + expect(failed.outMessage).toEqual( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Request timed out", + description: "The server is taking too long to respond. Please try again.", + }, + }), + ) + }) + + test("connecting a picked guild closes the modal, toasts, and refreshes the list", () => { const guild = { id: "918273645500901", name: "Design Systems Guild", icon: null, owner: true } const loaded = updateWithShared( updateWithShared( @@ -152,28 +213,29 @@ describe("chat sync connections", () => { expect(current.addModal.isOpen).toBe(false) expect(current.selectedGuild).toBeNull() expect(current.guildSearch).toBe("") - expect(current.connections._tag).toBe("Loading") + expect(current.submission).toEqual(Submission.NotSubmitted()) + expect(current.connections._tag).toBe("Refreshing") }), Command.resolve( ListConnections, Message.SucceededListConnections({ organizationId, connections: [connection] }), ), - Command.resolve( - ListDiscordGuilds, - Message.SucceededListDiscordGuilds({ organizationId, version: 2, guilds: [guild] }), - ), ) }) - test("a failed create keeps the modal open with an error toast", () => { + test("a failed create keeps the modal open with the reason's toast", () => { const failed = updateWithShared( - { ...init(undefined, shared).model, isCreating: true }, - Message.FailedCreateConnection({ toast: errorToast("Connection already exists") }), + { ...init(undefined, shared).model, submission: Submission.Submitting() }, + Message.FailedCreateConnection({ reason: "AlreadyExists" }), ) - expect(failed.model.isCreating).toBe(false) + expect(failed.model.submission).toEqual(Submission.Failed({ reason: "AlreadyExists" })) expect(failed.outMessage).toEqual( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Connection already exists", description: null }, + toast: { + intent: "error", + title: "Connection already exists", + description: "A connection to this Discord server already exists in your workspace.", + }, }), ) }) @@ -199,36 +261,39 @@ describe("chat sync guards", () => { ), Command.expectNone(), model((current) => expect(current.connections._tag).toBe("Loading")), - message(Message.FailedListConnections({ organizationId: otherOrganizationId })), + message( + Message.FailedListConnections({ organizationId: otherOrganizationId, reason: "Offline" }), + ), model((current) => expect(current.connections._tag).toBe("Loading")), ) }) - test("a second confirm while deleting sends no second delete", () => { + test("a second confirm while deleting sends no second delete; a failure re-asks", () => { story( updateWithShared, - given({ ...loaded, discordGuilds: { _tag: "Failed" } }), - message( - Message.ClickedDeleteConnection({ target: { id: connectionId, name: "Hazel Community" } }), - ), + given({ ...loaded, discordGuilds: DiscordGuilds.Failure({ error: "NotConnected" }) }), + message(Message.ClickedDeleteConnection({ target })), message(Message.ClickedConfirmDelete()), Command.expectExact(DeleteConnection({ syncConnectionId: connectionId })), - Command.resolve( - DeleteConnection, - Message.FailedDeleteConnection({ toast: errorToast("Connection not found", "Gone.") }), - ), + Command.resolve(DeleteConnection, Message.FailedDeleteConnection({ reason: "NotFound" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Connection not found", description: "Gone." }, + toast: { + intent: "error", + title: "Connection not found", + description: "This connection may have already been deleted.", + }, }), ), model((current) => { - expect(current.isDeleting).toBe(false) - expect(current.deleteModal.isOpen).toBe(true) + expect(current.deleteConfirm.state).toEqual({ _tag: "Confirming", target }) + expect(current.deleteConfirm.modal.isOpen).toBe(true) }), ) + const asked = ConfirmAction.ask(loaded.deleteConfirm, target) + const [running] = Option.getOrThrow(ConfirmAction.confirm(asked)) const deleting = updateWithShared( - { ...loaded, deleteTarget: { id: connectionId, name: "Hazel Community" }, isDeleting: true }, + { ...loaded, deleteConfirm: running }, Message.ClickedConfirmDelete(), ) expect(deleting.commands ?? []).toHaveLength(0) @@ -243,19 +308,23 @@ describe("chat sync guards", () => { message(Message.ClickedGuild({ guild })), message(Message.ClickedConnect()), Command.expectHas(CreateConnection), - model((current) => expect(current.isCreating).toBe(true)), + model((current) => expect(current.submission).toEqual(Submission.Submitting())), Command.resolve( CreateConnection, - Message.FailedCreateConnection({ toast: errorToast("Discord not connected") }), + Message.FailedCreateConnection({ reason: "DiscordNotConnected" }), ), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Discord not connected", description: null }, + toast: { + intent: "error", + title: "Discord not connected", + description: "Connect Discord first to load your guilds.", + }, }), ), ) const creating = updateWithShared( - { ...loaded, selectedGuild: guild, isCreating: true }, + { ...loaded, selectedGuild: guild, submission: Submission.Submitting() }, Message.ClickedConnect(), ) expect(creating.commands ?? []).toHaveLength(0) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/update.ts b/apps/web-foldkit/src/page/settings/chat-sync/update.ts index 64aafc06f..48fd0c982 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/update.ts @@ -1,26 +1,29 @@ import { OrganizationId, SyncConnectionId } from "@hazel/schema" -import { Effect, Exit, Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Command, Update } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import { AppRoute } from "../../../route" import { HazelRpc } from "../../../rpc" import * as Interaction from "../../../ui/aria/interaction" import * as Menu from "../../../ui/menu" import * as Modal from "../../../ui/modal" -import { failureToast, successToast } from "../../../data/actions" -import type { PageReturn, Shared } from "../../contract" +import { catchCommon, errorToastOf, reasonToast, successToast } from "../../../data/actions" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage, requestedOrgNavigation } from "../../out-message" -import { addMenuEntries, Message, type Model } from "./model" -import { fetchDiscordGuilds } from "./discord" +import * as ConfirmAction from "../../shared/confirm-action" +import { addMenuEntries, Connections, type DeleteTarget, Message, type Model, Submission } from "./model" +import { DiscordGuilds, fetchDiscordGuilds } from "./discord" import { fetchConnections } from "./rpc" import { ADD_CONNECTION_MODAL_ID, CreateConnection, FocusGuildSearch } from "./add-connection" -import { embedInteraction } from "../integrations/shared/interaction" type Return = PageReturn<Model, Message> type Step = Update.StepWithOutMessage<Model, Message, PageOutMessage> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) /** Interaction targets in the add connection modal. */ @@ -35,7 +38,7 @@ export const ListConnections = Command.define("ListConnections", { execute: ({ organizationId }) => fetchConnections(organizationId).pipe( Effect.map((connections) => Message.SucceededListConnections({ organizationId, connections })), - Effect.catch(() => Effect.succeed(Message.FailedListConnections({ organizationId }))), + catchCommon((reason) => Message.FailedListConnections({ organizationId, reason })), ), }) @@ -43,23 +46,14 @@ export const DeleteConnection = Command.define("DeleteConnection", { args: { syncConnectionId: SyncConnectionId }, messages: [Message.SucceededDeleteConnection, Message.FailedDeleteConnection], execute: ({ syncConnectionId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("chatSync.connection.delete", { syncConnectionId })) - return Exit.match(exit, { - onSuccess: () => Message.SucceededDeleteConnection(), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast", { - ChatSyncConnectionNotFoundError: { - title: "Connection not found", - description: "This connection may have already been deleted.", - isRetryable: false, - }, - }) - return Message.FailedDeleteConnection({ toast }) - }, - }) - }), + HazelRpc.use((rpc) => rpc("chatSync.connection.delete", { syncConnectionId })).pipe( + Effect.as(Message.SucceededDeleteConnection()), + Effect.catchTags({ + ChatSyncConnectionNotFoundError: () => + Effect.succeed(Message.FailedDeleteConnection({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedDeleteConnection({ reason })), + ), }) /** `AddConnectionModal`'s guild query; the modal mounts with the loaded list. */ @@ -69,7 +63,17 @@ export const ListDiscordGuilds = Command.define("ListDiscordGuilds", { execute: ({ organizationId, version }) => fetchDiscordGuilds(organizationId).pipe( Effect.map((guilds) => Message.SucceededListDiscordGuilds({ organizationId, version, guilds })), - Effect.catch(() => Effect.succeed(Message.FailedListDiscordGuilds({ organizationId, version }))), + Effect.catchTags({ + IntegrationNotConnectedForPreviewError: () => + Effect.succeed( + Message.FailedListDiscordGuilds({ organizationId, version, reason: "NotConnected" }), + ), + IntegrationResourceError: () => + Effect.succeed( + Message.FailedListDiscordGuilds({ organizationId, version, reason: "ProviderError" }), + ), + }), + catchCommon((reason) => Message.FailedListDiscordGuilds({ organizationId, version, reason })), ), }) @@ -82,7 +86,7 @@ const requestList = (model: Model, shared: Shared): Return => { return { model: modifyFields(model, { requestedOrganizationId: () => organizationId, - connections: () => ({ _tag: "Loading" as const }), + connections: () => Connections.Loading(), }), commands: [ListConnections({ organizationId })], } @@ -92,7 +96,7 @@ export const init = (_route: unknown, shared: Shared): Return => requestList( { requestedOrganizationId: null, - connections: { _tag: "Loading" }, + connections: Connections.Loading(), addMenu: Menu.init({ id: "chat-sync-add", entries: addMenuEntries, placement: "bottom end" }), emptyAddMenu: Menu.init({ id: "chat-sync-add-empty", @@ -100,15 +104,13 @@ export const init = (_route: unknown, shared: Shared): Return => placement: "bottom end", }), addModal: Modal.init(ADD_CONNECTION_MODAL_ID), - discordGuilds: { _tag: "Loading" }, + discordGuilds: DiscordGuilds.Loading(), guildsVersion: 0, selectedGuild: null, guildSearch: "", isGuildSearchFocused: false, - isCreating: false, - deleteTarget: null, - deleteModal: Modal.init("chat-sync-delete"), - isDeleting: false, + submission: Submission.NotSubmitted(), + deleteConfirm: ConfirmAction.init("chat-sync-delete"), interaction: Interaction.init(), }, shared, @@ -118,7 +120,32 @@ export const sharedChanged = (model: Model, shared: Shared): Return => requestLi // UPDATE -/** `handleClose`: a closed add modal has no selection and no search. */ +/** `setRefreshKey(k => k + 1)`: the list loads again, keeping the shown list while it does. */ +const reloadConnections = (model: Model): Pick<Return, "model" | "commands"> => { + const organizationId = model.requestedOrganizationId + if (organizationId === null) return { model } + return { + model: modifyFields(model, { + connections: (connections) => + Option.getOrElse(AsyncData.revalidateOrLoad(connections), () => connections), + }), + commands: [ListConnections({ organizationId })], + } +} + +/** A new guild request; a reply for an older one is dropped. */ +const requestGuilds = (model: Model, organizationId: OrganizationId): Return => { + const version = model.guildsVersion + 1 + return { + model: modifyFields(model, { + discordGuilds: () => DiscordGuilds.Loading(), + guildsVersion: () => version, + }), + commands: [ListDiscordGuilds({ organizationId, version })], + } +} + +/** `handleClose`: a closed add modal has no selection, no search and no failed attempt. */ const addModal = { read: (model: Model) => Option.some(model.addModal), write: (model: Model, modal: Modal.Model): Model => @@ -129,6 +156,8 @@ const addModal = { selectedGuild: () => null, guildSearch: () => "", isGuildSearchFocused: () => false, + submission: (submission) => + submission._tag === "Failed" ? Submission.NotSubmitted() : submission, }), toParentMessage: (message: Modal.Message) => Message.GotAddModalMessage({ message }), } @@ -147,16 +176,17 @@ const foldAddModal = (model: Model, message: Modal.Message): Return => { } /** The search Input is rendered while the guilds are listed and none is selected. */ -const hasGuildSearch = (model: Model) => model.discordGuilds._tag === "Loaded" && model.selectedGuild === null +const hasGuildSearch = (model: Model) => + AsyncData.isSuccess(model.discordGuilds) && model.selectedGuild === null const submitConnection = (model: Model): Return => { const organizationId = model.requestedOrganizationId const guild = model.selectedGuild - if (organizationId === null || guild === null || model.isCreating) return { model } + if (organizationId === null || guild === null || model.submission._tag === "Submitting") return { model } return { // The Connect button disables while it runs, which ends its hover (useHover). model: modifyFields(model, { - isCreating: () => true, + submission: () => Submission.Submitting(), interaction: (state) => Interaction.disabledTargets(state, [CONNECT_TARGET]), }), commands: [ @@ -193,25 +223,18 @@ const foldEmptyAddMenu = Update.foldChild({ foldOutMessage: foldAddMenuOutMessage, }) -/** `onOpenChange={(open) => !open && setDeleteTarget(null)}` */ -const deleteModal = { - read: (model: Model) => Option.some(model.deleteModal), - write: (model: Model, modal: Modal.Model): Model => - modifyFields(model, { - deleteModal: () => modal, - deleteTarget: (target) => (modal.isOpen ? target : null), - }), - toParentMessage: (message: Modal.Message) => Message.GotDeleteModalMessage({ message }), -} -const foldDeleteModal = Update.foldChild({ update: Modal.update, ...deleteModal }) -const openDeleteModal = Update.foldChildStep({ update: Modal.open, ...deleteModal }) -const closeDeleteModal = Update.foldChildStep({ update: Modal.close, ...deleteModal }) - -const withCommands = (result: Return, commands: Return["commands"]): Return => ({ - ...result, - commands: [...(result.commands ?? []), ...(commands ?? [])], +const foldDeleteConfirm = Update.foldChild({ + update: (confirm: ConfirmAction.Model<DeleteTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(confirm, message), + read: (model: Model) => Option.some(model.deleteConfirm), + write: (model: Model, deleteConfirm: ConfirmAction.Model<DeleteTarget>): Model => + modifyFields(model, { deleteConfirm: () => deleteConfirm }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotDeleteConfirmMessage({ message }), }) +const withDeleteConfirm = (model: Model, deleteConfirm: ConfirmAction.Model<DeleteTarget>): Model => + modifyFields(model, { deleteConfirm: () => deleteConfirm }) + const isCurrentGuildList = (model: Model, organizationId: OrganizationId, version: number) => organizationId === model.requestedOrganizationId && version === model.guildsVersion @@ -220,75 +243,87 @@ export const update = (model: Model, message: Message, shared: Shared): Return = SucceededListConnections: ({ organizationId, connections }) => { if (organizationId !== model.requestedOrganizationId) return { model } const next = modifyFields(model, { - connections: () => ({ _tag: "Loaded" as const, connections }), + connections: () => Connections.Success({ data: connections }), }) - // The loading and error states return early, so a fresh list remounts the modal. - if (model.connections._tag === "Loaded") return { model: next } - const version = model.guildsVersion + 1 - return { - model: modifyFields(next, { - discordGuilds: () => ({ _tag: "Loading" as const }), - guildsVersion: () => version, + // The add modal mounts with the first list and queries the guilds then; a refresh keeps it mounted. + return AsyncData.hasData(model.connections) + ? { model: next } + : requestGuilds(next, organizationId) + }, + FailedListConnections: ({ organizationId, reason }) => { + if (organizationId !== model.requestedOrganizationId) return { model } + // A failed refresh keeps the shown list and says so; a failed first load replaces the spinner. + return Option.match(AsyncData.getData(model.connections), { + onSome: (data) => ({ + model: modifyFields(model, { + connections: () => Connections.Stale({ error: reason, data }), + }), + outMessage: PageOutMessage.RequestedToast({ toast: reasonToast(reason) }), }), - commands: [ListDiscordGuilds({ organizationId, version })], - } + onNone: () => ({ + model: modifyFields(model, { connections: () => Connections.Failure({ error: reason }) }), + }), + }) }, + ClickedRetryListConnections: () => reloadConnections(model), SucceededListDiscordGuilds: ({ organizationId, version, guilds }) => { if (!isCurrentGuildList(model, organizationId, version)) return { model } - const next = modifyFields(model, { - discordGuilds: () => ({ _tag: "Loaded" as const, items: guilds }), - }) + const next = modifyFields(model, { discordGuilds: () => DiscordGuilds.Success({ data: guilds }) }) // The search mounts with `autoFocus` when the guilds arrive while the modal is open. return { model: next, commands: model.addModal.isOpen && hasGuildSearch(next) ? [FocusGuildSearch()] : [], } }, - FailedListDiscordGuilds: ({ organizationId, version }) => + FailedListDiscordGuilds: ({ organizationId, version, reason }) => isCurrentGuildList(model, organizationId, version) - ? { model: modifyFields(model, { discordGuilds: () => ({ _tag: "Failed" as const }) }) } - : { model }, - FailedListConnections: ({ organizationId }) => - organizationId === model.requestedOrganizationId - ? { model: modifyFields(model, { connections: () => ({ _tag: "Failed" as const }) }) } + ? { + model: modifyFields(model, { + discordGuilds: () => DiscordGuilds.Failure({ error: reason }), + }), + } : { model }, + ClickedRetryListDiscordGuilds: () => + model.requestedOrganizationId === null + ? { model } + : requestGuilds(model, model.requestedOrganizationId), ClickedConnection: ({ connectionId }) => ({ model, outMessage: requestedOrgNavigation(shared.orgSlug, (orgSlug) => AppRoute.SettingsChatSyncConnection({ orgSlug, connectionId }), ), }), - ClickedDeleteConnection: ({ target }) => - openDeleteModal(modifyFields(model, { deleteTarget: () => target })), + ClickedDeleteConnection: ({ target }) => ({ + model: withDeleteConfirm(model, ConfirmAction.ask(model.deleteConfirm, target)), + }), ClickedConfirmDelete: () => - model.deleteTarget === null || model.isDeleting - ? { model } - : { - model: modifyFields(model, { isDeleting: () => true }), - commands: [DeleteConnection({ syncConnectionId: model.deleteTarget.id })], - }, - // `setRefreshKey(k => k + 1)`: a new query key, so the list loads again. - SucceededDeleteConnection: () => { - const organizationId = model.requestedOrganizationId - const closed = closeDeleteModal( - modifyFields(model, { - isDeleting: () => false, - connections: (connections) => - organizationId === null ? connections : { _tag: "Loading" as const }, + Option.match(ConfirmAction.confirm(model.deleteConfirm), { + onNone: () => ({ model }), + onSome: ([running, target]) => ({ + model: withDeleteConfirm(model, running), + commands: [DeleteConnection({ syncConnectionId: target.id })], }), - ) - return { - ...withCommands(closed, organizationId === null ? [] : [ListConnections({ organizationId })]), - outMessage: PageOutMessage.RequestedToast({ toast: successToast("Connection deleted") }), - } - }, - FailedDeleteConnection: ({ toast }) => ({ - model: modifyFields(model, { isDeleting: () => false }), - outMessage: PageOutMessage.RequestedToast({ toast }), + }), + SucceededDeleteConnection: () => ({ + ...reloadConnections(withDeleteConfirm(model, ConfirmAction.close(model.deleteConfirm))), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Connection deleted") }), + }), + FailedDeleteConnection: ({ reason }) => ({ + model: withDeleteConfirm(model, ConfirmAction.failed(model.deleteConfirm)), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Connection not found", + description: "This connection may have already been deleted.", + }, + }), + ), + }), }), GotAddMenuMessage: ({ message: menuMessage }) => foldAddMenu(model, menuMessage), GotEmptyAddMenuMessage: ({ message: menuMessage }) => foldEmptyAddMenu(model, menuMessage), - GotDeleteModalMessage: ({ message: modalMessage }) => foldDeleteModal(model, modalMessage), + GotDeleteConfirmMessage: ({ message: confirmMessage }) => foldDeleteConfirm(model, confirmMessage), GotAddModalMessage: ({ message: modalMessage }) => foldAddModal(model, modalMessage), ChangedGuildSearch: ({ value }) => ({ model: modifyFields(model, { guildSearch: () => value }) }), FocusedGuildSearch: () => ({ model: modifyFields(model, { isGuildSearchFocused: () => true }) }), @@ -315,24 +350,29 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedConnect: () => submitConnection(model), GotInteractionMessage: ({ message }) => interaction.fold(model, message), // `onSuccess()` reloads the list (a new query key), then `handleClose()`. - SucceededCreateConnection: () => { - const organizationId = model.requestedOrganizationId - const closed = closeAddModal( - modifyFields(model, { - isCreating: () => false, - connections: (connections) => - organizationId === null ? connections : { _tag: "Loading" as const }, - }), - ) - return { - ...withCommands(closed, organizationId === null ? [] : [ListConnections({ organizationId })]), - outMessage: PageOutMessage.RequestedToast({ - toast: successToast("Discord connection created"), - }), - } - }, - FailedCreateConnection: ({ toast }) => ({ - model: modifyFields(model, { isCreating: () => false }), - outMessage: PageOutMessage.RequestedToast({ toast }), + SucceededCreateConnection: () => ({ + ...Update.combine(modifyFields(model, { submission: () => Submission.NotSubmitted() }), [ + closeAddModal, + reloadConnections, + ]), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Discord connection created") }), + }), + FailedCreateConnection: ({ reason }) => ({ + model: modifyFields(model, { submission: () => Submission.Failed({ reason }) }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + AlreadyExists: { + title: "Connection already exists", + description: + "A connection to this Discord server already exists in your workspace.", + }, + DiscordNotConnected: { + title: "Discord not connected", + description: "Connect Discord first to load your guilds.", + }, + }), + ), + }), }), }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/view.ts b/apps/web-foldkit/src/page/settings/chat-sync/view.ts index ee5dcc801..ccb98b5c8 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/view.ts @@ -1,13 +1,16 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { buttonStyles } from "~/components/ui/button.styles" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" import { IconArrowPath, IconChevronDown, IconPlus, IconTrash, IconWarning } from "../../../icons" import { badge } from "../../../ui/badge" +import { button } from "../../../ui/button" import { emptyState } from "../../../ui/empty-state" import { loader } from "../../../ui/loader" import type * as Menu from "../../../ui/menu" import { menuTriggerClassName, view as menuView } from "../../../ui/menu-view" -import type * as Modal from "../../../ui/modal" +import type * as ConfirmAction from "../../shared/confirm-action" import { sectionHeaderGroup, sectionHeaderHeading, @@ -25,7 +28,8 @@ import { type Connection, Message, type Model, STATUS_CONFIG } from "./model" const toAddMenuMessage = (message: Menu.Message) => Message.GotAddMenuMessage({ message }) const toEmptyAddMenuMessage = (message: Menu.Message) => Message.GotEmptyAddMenuMessage({ message }) -const toDeleteModalMessage = (message: Modal.Message) => Message.GotDeleteModalMessage({ message }) +const toDeleteConfirmMessage = (message: ConfirmAction.Message) => + Message.GotDeleteConfirmMessage({ message }) const header = (h: HtmlBuilder<Message>, actions: ReadonlyArray<Html>): Html => sectionHeaderRoot(h, { className: "border-none pb-0" }, [ @@ -227,19 +231,18 @@ const connectionCard = (h: HtmlBuilder<Message>, connection: Connection): Html = const deleteModal = (h: HtmlBuilder<Message>, model: Model): Html => confirmDialog(h, { slotId: "delete-connection", - modal: model.deleteModal, + confirm: model.deleteConfirm, icon: IconWarning(h, { className: "size-6 text-danger" }), title: "Delete Connection", - description: [ + description: (target) => [ "Are you sure you want to delete the connection to", - model.deleteTarget?.name ?? "", + target.name, "? This will also remove all linked channels. This action cannot be undone.", ], confirmLabel: "Delete Connection", pendingLabel: "Deleting...", - isPending: model.isDeleting, onConfirm: Message.ClickedConfirmDelete(), - toParentMessage: toDeleteModalMessage, + toParentMessage: toDeleteConfirmMessage, }) /** Legacy returns a fragment into the column layout; `display: contents` keeps its children flex items. */ @@ -278,21 +281,40 @@ const loadedBody = (h: HtmlBuilder<Message>, model: Model, connections: Readonly connections.map((connection) => connectionCard(h, connection)), ) -export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => { - const connections = model.connections - if (connections._tag === "Loading") return fragment(h, [header(h, []), loadingState(h)]) - if (connections._tag === "Failed") - return fragment(h, [ - header(h, []), - emptyState(h, { - title: "Failed to load connections", - description: "Something went wrong loading your sync connections. Please try refreshing.", - }), - ]) - return fragment(h, [ +/** A first load that failed: what went wrong, and "Try again" when it may pass on a retry. */ +const failureState = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return emptyState(h, { + title: "Failed to load connections", + description: message.description ?? message.title, + ...(isRetryableReason(reason) + ? { + action: button( + h, + { intent: "secondary", onPress: Message.ClickedRetryListConnections() }, + ["Try again"], + ), + } + : {}), + }) +} + +const loaded = (h: HtmlBuilder<Message>, model: Model, connections: ReadonlyArray<Connection>): Html => + fragment(h, [ header(h, [addConnectionDropdown(h, "add-connection", model.addMenu, toAddMenuMessage)]), - loadedBody(h, model, connections.connections), + loadedBody(h, model, connections), addConnectionModal(h, model), deleteModal(h, model), ]) -}) + +export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => + AsyncData.match(model.connections, { + onIdle: () => fragment(h, [header(h, []), loadingState(h)]), + onLoading: () => fragment(h, [header(h, []), loadingState(h)]), + onFailure: (reason) => fragment(h, [header(h, []), failureState(h, reason)]), + // A refresh (or a failed one, which toasts) keeps the shown list. + onRefreshing: (connections) => loaded(h, model, connections), + onStale: ({ data }) => loaded(h, model, data), + onSuccess: (connections) => loaded(h, model, connections), + }), +) diff --git a/apps/web-foldkit/src/page/settings/connect-invites/message.ts b/apps/web-foldkit/src/page/settings/connect-invites/message.ts index ec58c5893..fb8efb31e 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/message.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/message.ts @@ -1,9 +1,9 @@ import { ConnectInviteId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" -import { HostOrganization, Invite } from "./model" +import { AcceptFailure, DeclineFailure, HostOrganization, Invite } from "./model" export const Message = defineMessageUnion({ SucceededListInvites: { @@ -11,14 +11,15 @@ export const Message = defineMessageUnion({ version: Schema.Number, invites: Schema.Array(Invite), }, - FailedListInvites: { organizationId: OrganizationId, version: Schema.Number }, + FailedListInvites: { organizationId: OrganizationId, version: Schema.Number, reason: CommonReason }, + ClickedRetryListInvites: {}, UpdatedHostOrganizations: { organizations: Schema.Array(HostOrganization) }, ClickedAccept: { inviteId: ConnectInviteId }, SucceededAccept: { inviteId: ConnectInviteId }, - FailedAccept: { inviteId: ConnectInviteId, toast: ToastRequest }, + FailedAccept: { inviteId: ConnectInviteId, reason: AcceptFailure }, ClickedDecline: { inviteId: ConnectInviteId }, SucceededDecline: { inviteId: ConnectInviteId }, - FailedDecline: { inviteId: ConnectInviteId, toast: ToastRequest }, + FailedDecline: { inviteId: ConnectInviteId, reason: DeclineFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/connect-invites/model.ts b/apps/web-foldkit/src/page/settings/connect-invites/model.ts index 2dd6853fa..ebbbde4f3 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/model.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/model.ts @@ -1,5 +1,7 @@ import { ConnectInviteId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" export const Invite = Schema.Struct({ @@ -13,12 +15,27 @@ export type Invite = typeof Invite.Type export const HostOrganization = Schema.Struct({ id: OrganizationId, name: Schema.String }) export type HostOrganization = typeof HostOrganization.Type +/** The incoming invites; `connectShare.invite.listIncoming` fails only with the shared reasons. */ +export const Invites = AsyncData.Schema(Schema.Array(Invite), CommonReason) +export type Invites = typeof Invites.schema.Type + +/** Why declining failed, beyond the shared reasons. */ +export const DeclineFailure = Schema.Union([ + Schema.Literals(["NotFound", "InvalidState", "WorkspaceNotFound"]), + CommonReason, +]) +export type DeclineFailure = typeof DeclineFailure.Type + +/** Why accepting failed: the decline reasons, and the channel already being shared. */ +export const AcceptFailure = Schema.Union([Schema.Literal("AlreadyShared"), DeclineFailure]) +export type AcceptFailure = typeof AcceptFailure.Type + export const Model = Schema.Struct({ /** The organization the list was last requested for (the legacy query atom's key). */ requestedFor: Schema.NullOr(OrganizationId), /** Bumped per list request; an older response is dropped. */ listVersion: Schema.Number, - invites: Schema.Array(Invite), + invites: Invites.schema, hostOrganizations: Schema.Array(HostOrganization), acceptingIds: Schema.Array(ConnectInviteId), decliningIds: Schema.Array(ConnectInviteId), diff --git a/apps/web-foldkit/src/page/settings/connect-invites/scene.test.ts b/apps/web-foldkit/src/page/settings/connect-invites/scene.test.ts index 9090863d8..fbdbc1f9b 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/scene.test.ts @@ -3,17 +3,11 @@ import { ConnectInviteId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { successToast } from "../../../data/actions" -import { - failureToastFixture, - makeShared, - organizationId, - pageScene, - uuid, -} from "../../../test/pages-fixtures" +import { reasonToast, successToast } from "../../../data/actions" +import { makeShared, organizationId, pageScene, uuid } from "../../../test/pages-fixtures" import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import type { Invite } from "./model" +import { type Invite, Invites } from "./model" import { AcceptInvite, DeclineInvite, init, ListIncomingInvites, update } from "./update" import { view } from "./view" @@ -27,6 +21,7 @@ const acceptedId = Schema.decodeSync(ConnectInviteId)(uuid(22)) const pending: Invite = { id: inviteId, hostOrganizationId: hostId, status: "pending", createdAtMs: 0 } const accepted: Invite = { id: acceptedId, hostOrganizationId: hostId, status: "accepted", createdAtMs: 0 } const start = init(undefined, shared).model +const listed = (invites: ReadonlyArray<Invite>) => ({ ...start, invites: Invites.Success({ data: invites }) }) const listInvites = (version: number, invites: ReadonlyArray<Invite>) => Scene.Command.resolve( ListIncomingInvites({ organizationId, version }), @@ -40,12 +35,12 @@ describe("list", () => { test("the empty state, then rows with the host name once the live query names it", () => { Scene.scene( config, - Scene.given(start), + Scene.given(listed([])), Scene.expect(Scene.role("heading", { name: "No connect invitations" })).toExist(), ) Scene.scene( config, - Scene.given({ ...start, invites: [accepted, pending] }), + Scene.given(listed([accepted, pending])), Scene.expect(Scene.text("1 pending", { exact: false })).toExist(), Scene.expect(Scene.text(hostId)).toExist(), Scene.Subscription.emit( @@ -59,11 +54,32 @@ describe("list", () => { }) }) +describe("loading", () => { + test("the first load shows a loader; a failure shows the error and a retry, not the empty state", () => { + Scene.scene( + config, + Scene.given(start), + Scene.expect(Scene.text("Loading invitations...")).toExist(), + Scene.expect(Scene.role("heading", { name: "No connect invitations" })).toBeAbsent(), + ) + Scene.scene( + config, + Scene.given({ ...start, invites: Invites.Failure({ error: "Unavailable" }) }), + Scene.expect(Scene.role("heading", { name: "Couldn't load connect invitations" })).toExist(), + Scene.expect(Scene.role("heading", { name: "No connect invitations" })).toBeAbsent(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(ListIncomingInvites({ organizationId, version: 2 })), + listInvites(2, [pending]), + Scene.expect(accept).toExist(), + ) + }) +}) + describe("accept and decline", () => { test("Accept disables both buttons, toasts on success and refetches the list", () => { Scene.scene( config, - Scene.given({ ...start, invites: [pending] }), + Scene.given(listed([pending])), Scene.click(accept), Scene.Command.expectExact(AcceptInvite({ inviteId, guestOrganizationId: organizationId })), Scene.expect(Scene.role("button", { name: "Accepting..." })).toBeDisabled(), @@ -82,15 +98,12 @@ describe("accept and decline", () => { test("a failed decline restores the buttons and shows the failure toast", () => { Scene.scene( config, - Scene.given({ ...start, invites: [pending] }), + Scene.given(listed([pending])), Scene.click(decline), Scene.Command.expectExact(DeclineInvite({ inviteId })), Scene.expect(Scene.role("button", { name: "Declining..." })).toBeDisabled(), - Scene.Command.resolve( - DeclineInvite, - Message.FailedDecline({ inviteId, toast: failureToastFixture }), - ), - Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Scene.Command.resolve(DeclineInvite, Message.FailedDecline({ inviteId, reason: "Unexpected" })), + Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Unexpected") })), Scene.expect(decline).toBeEnabled(), Scene.expect(accept).toBeEnabled(), ) diff --git a/apps/web-foldkit/src/page/settings/connect-invites/story.test.ts b/apps/web-foldkit/src/page/settings/connect-invites/story.test.ts index 70c9271dd..d7ddff593 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/story.test.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/story.test.ts @@ -6,9 +6,17 @@ import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../out-message" import type { Shared } from "../../contract" import { Message } from "./message" -import { AcceptInvite, DeclineInvite, init, ListIncomingInvites, sharedChanged, update } from "./update" -import { successToast } from "../../../data/actions" -import { failureToastFixture } from "../../../test/pages-fixtures" +import { Invites } from "./model" +import { + AcceptInvite, + DeclineInvite, + init, + invitesOf, + ListIncomingInvites, + sharedChanged, + update, +} from "./update" +import { errorToast, reasonToast, successToast } from "../../../data/actions" import { sharedDefaults } from "../../test-shared" /** Update-loop tests for connect invitations: list requests and the accept/decline lifecycle. */ @@ -56,8 +64,12 @@ describe("connect invitations", () => { test("a failed decline keeps the list and reports the error", () => { const loaded = init(undefined, shared(hazel)).model const declining = update(loaded, Message.ClickedDecline({ inviteId }), shared(hazel)).model - const toast = { intent: "error" as const, title: "Cannot decline", description: null } - const failed = update(declining, Message.FailedDecline({ inviteId, toast }), shared(hazel)) + const toast = errorToast("Cannot decline", "This invite is no longer in a declinable state.") + const failed = update( + declining, + Message.FailedDecline({ inviteId, reason: "InvalidState" }), + shared(hazel), + ) expect(failed.model.decliningIds).toEqual([]) expect(failed.commands ?? []).toEqual([]) expect(failed.outMessage).toMatchObject({ toast }) @@ -66,17 +78,13 @@ describe("connect invitations", () => { describe("accept failure", () => { test("Accept sends connectShare.invite.accept for this organization and toasts the failure", () => { - const toast = { - intent: "error" as const, - title: "Cannot accept", - description: "This invite is no longer in an acceptable state.", - } + const toast = errorToast("Cannot accept", "This invite is no longer in an acceptable state.") story( (current: Parameters<typeof update>[0], next: Message) => update(current, next, shared(hazel)), given(init(undefined, shared(hazel)).model), message(Message.ClickedAccept({ inviteId })), Command.expectExact(AcceptInvite({ inviteId, guestOrganizationId: hazel })), - Command.resolve(AcceptInvite, Message.FailedAccept({ inviteId, toast })), + Command.resolve(AcceptInvite, Message.FailedAccept({ inviteId, reason: "InvalidState" })), expectOutMessage(PageOutMessage.RequestedToast({ toast })), model((current) => expect(current.acceptingIds).toEqual([])), ) @@ -91,16 +99,34 @@ const listed = run( ).model describe("list", () => { - test("a failed list shows the empty list and toasts nothing", () => { + test("a failed first list is a Failure, not an empty list, and toasts nothing", () => { story( run, - given(listed), - message(Message.FailedListInvites({ organizationId: hazel, version: 1 })), + given(init(undefined, shared(hazel)).model), + model((current) => expect(current.invites).toEqual(Invites.Loading())), + message(Message.FailedListInvites({ organizationId: hazel, version: 1, reason: "Offline" })), expectNoOutMessage(), - model((current) => expect(current.invites).toEqual([])), + model((current) => expect(current.invites).toEqual(Invites.Failure({ error: "Offline" }))), + message(Message.ClickedRetryListInvites()), + Command.expectExact(ListIncomingInvites({ organizationId: hazel, version: 2 })), + model((current) => expect(current.invites).toEqual(Invites.Loading())), + Command.resolve( + ListIncomingInvites, + Message.SucceededListInvites({ organizationId: hazel, version: 2, invites: [pending] }), + ), + model((current) => expect(current.invites).toEqual(Invites.Success({ data: [pending] }))), ) }) + test("a failed refetch keeps the listed invites as Stale", () => { + const refetching = { ...listed, listVersion: 2 } + const failed = run( + refetching, + Message.FailedListInvites({ organizationId: hazel, version: 2, reason: "Timeout" }), + ) + expect(failed.model.invites).toEqual(Invites.Stale({ error: "Timeout", data: [pending] })) + }) + test("an older list or one for another organization is dropped", () => { const other = Schema.decodeSync(OrganizationId)(uuid(9)) const refetching = { ...listed, listVersion: 2 } @@ -108,20 +134,23 @@ describe("list", () => { refetching, Message.SucceededListInvites({ organizationId: hazel, version: 1, invites: [] }), ) - expect(stale.model.invites).toEqual([pending]) + expect(invitesOf(stale.model)).toEqual([pending]) const foreign = run( refetching, Message.SucceededListInvites({ organizationId: other, version: 2, invites: [] }), ) - expect(foreign.model.invites).toEqual([pending]) - const staleFailure = run(refetching, Message.FailedListInvites({ organizationId: hazel, version: 1 })) - expect(staleFailure.model.invites).toEqual([pending]) + expect(invitesOf(foreign.model)).toEqual([pending]) + const staleFailure = run( + refetching, + Message.FailedListInvites({ organizationId: hazel, version: 1, reason: "Offline" }), + ) + expect(staleFailure.model.invites).toEqual(Invites.Success({ data: [pending] })) }) test("a new organization drops the old list and requests its own", () => { const other = Schema.decodeSync(OrganizationId)(uuid(9)) const switched = sharedChanged(listed, shared(other)) - expect(switched.model.invites).toEqual([]) + expect(switched.model.invites).toEqual(Invites.Loading()) expect(switched.model.requestedFor).toBe(other) expect(switched.commands?.map((command) => command.name)).toEqual([ListIncomingInvites.name]) }) @@ -144,7 +173,7 @@ describe("decline", () => { ), model((current) => { expect(current.decliningIds).toEqual([]) - expect(current.invites).toEqual([]) + expect(invitesOf(current)).toEqual([]) }), ) }) @@ -154,8 +183,8 @@ describe("decline", () => { run, given(listed), message(Message.ClickedDecline({ inviteId })), - Command.resolve(DeclineInvite, Message.FailedDecline({ inviteId, toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Command.resolve(DeclineInvite, Message.FailedDecline({ inviteId, reason: "Unexpected" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Unexpected") })), Command.expectNone(), ) }) diff --git a/apps/web-foldkit/src/page/settings/connect-invites/subscription.ts b/apps/web-foldkit/src/page/settings/connect-invites/subscription.ts index 22618f185..84d572905 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/subscription.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/subscription.ts @@ -6,7 +6,7 @@ import { organizationCollection } from "~/db/collections" import { liveQueryStream } from "../../../data/live-query" import type { PageSubscriptionInput } from "../../contract" import { Message } from "./message" -import { interaction } from "./update" +import { interaction, invitesOf } from "./update" import type { Model } from "./model" /** Host organization names (legacy: one `organizationCollection` live query per row). */ @@ -16,7 +16,7 @@ const dataSubscriptions = Subscription.make<PageSubscriptionInput<Model>, Messag { modelToDependencies: ({ model }) => ({ organizationIds: [ - ...new Set(model.invites.map((invite) => invite.hostOrganizationId)), + ...new Set(invitesOf(model).map((invite) => invite.hostOrganizationId)), ].sort(), }), dependenciesToStream: ({ organizationIds }) => diff --git a/apps/web-foldkit/src/page/settings/connect-invites/update.ts b/apps/web-foldkit/src/page/settings/connect-invites/update.ts index 5db799f75..bf703f1ac 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/update.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/update.ts @@ -1,21 +1,23 @@ import { ConnectInviteId, OrganizationId } from "@hazel/schema" -import { Effect, Schema } from "effect" +import { Effect, Option, Result, Schema } from "effect" import { Command } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import { toDate } from "~/lib/utils" +import { catchCommon, errorToastOf, successToast } from "../../../data/actions" import { HazelRpc } from "../../../rpc" -import type { PageReturn, Shared } from "../../contract" -import { PageOutMessage } from "../../out-message" -import { failureToast, settle, successToast } from "../../../data/actions" import * as Interaction from "../../../ui/aria/interaction" -import { embedInteraction } from "../integrations/shared/interaction" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" +import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import type { Model } from "./model" +import { type AcceptFailure, type DeclineFailure, type Invite, Invites, type Model } from "./model" type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) /** The interaction targets of one invite's Decline and Accept buttons. */ @@ -29,9 +31,9 @@ const busy = (model: Model, inviteId: ConnectInviteId) => Interaction.disabledTargets(state, [declineTarget(inviteId), acceptTarget(inviteId)]), }) -const inviteNotFound = (description: string) => ({ - ConnectInviteNotFoundError: { title: "Invite not found", description }, -}) +/** The invites on screen: the loaded list, kept while a refetch runs or after one failed. */ +export const invitesOf = (model: Model): ReadonlyArray<Invite> => + Option.getOrElse(AsyncData.getData(model.invites), () => []) // COMMAND @@ -39,12 +41,8 @@ export const ListIncomingInvites = Command.define("ListIncomingInvites", { args: { organizationId: OrganizationId, version: Schema.Number }, messages: [Message.SucceededListInvites, Message.FailedListInvites], execute: ({ organizationId, version }) => - settle( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("connectShare.invite.listIncoming", { organizationId }) - }), - (response) => + HazelRpc.use((client) => client("connectShare.invite.listIncoming", { organizationId })).pipe( + Effect.map((response) => Message.SucceededListInvites({ organizationId, version, @@ -55,68 +53,46 @@ export const ListIncomingInvites = Command.define("ListIncomingInvites", { createdAtMs: toDate(invite.createdAt).getTime(), })), }), - () => Message.FailedListInvites({ organizationId, version }), + ), + catchCommon((reason) => Message.FailedListInvites({ organizationId, version, reason })), ), }) export const AcceptInvite = Command.define("AcceptInvite", { args: { inviteId: ConnectInviteId, guestOrganizationId: OrganizationId }, messages: [Message.SucceededAccept, Message.FailedAccept], - execute: ({ inviteId, guestOrganizationId }) => - settle( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("connectShare.invite.accept", { inviteId, guestOrganizationId }) + execute: ({ inviteId, guestOrganizationId }) => { + const failed = (reason: AcceptFailure) => Effect.succeed(Message.FailedAccept({ inviteId, reason })) + return HazelRpc.use((client) => + client("connectShare.invite.accept", { inviteId, guestOrganizationId }), + ).pipe( + Effect.as(Message.SucceededAccept({ inviteId })), + Effect.catchTags({ + ConnectInviteNotFoundError: () => failed("NotFound"), + ConnectInviteInvalidStateError: () => failed("InvalidState"), + ConnectWorkspaceNotFoundError: () => failed("WorkspaceNotFound"), + ConnectChannelAlreadySharedError: () => failed("AlreadyShared"), }), - () => Message.SucceededAccept({ inviteId }), - (cause) => - Message.FailedAccept({ - inviteId, - toast: failureToast(cause, "friendly", { - ...inviteNotFound("This invite may have been revoked or expired."), - ConnectInviteInvalidStateError: { - title: "Cannot accept", - description: "This invite is no longer in an acceptable state.", - }, - ConnectWorkspaceNotFoundError: { - title: "Workspace not found", - description: "The target workspace could not be found.", - }, - ConnectChannelAlreadySharedError: { - title: "Already shared", - description: "This channel is already shared with that organization.", - }, - }), - }), - ), + catchCommon((reason) => Message.FailedAccept({ inviteId, reason })), + ) + }, }) export const DeclineInvite = Command.define("DeclineInvite", { args: { inviteId: ConnectInviteId }, messages: [Message.SucceededDecline, Message.FailedDecline], - execute: ({ inviteId }) => - settle( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("connectShare.invite.decline", { inviteId }) + execute: ({ inviteId }) => { + const failed = (reason: DeclineFailure) => Effect.succeed(Message.FailedDecline({ inviteId, reason })) + return HazelRpc.use((client) => client("connectShare.invite.decline", { inviteId })).pipe( + Effect.as(Message.SucceededDecline({ inviteId })), + Effect.catchTags({ + ConnectInviteNotFoundError: () => failed("NotFound"), + ConnectInviteInvalidStateError: () => failed("InvalidState"), + ConnectWorkspaceNotFoundError: () => failed("WorkspaceNotFound"), }), - () => Message.SucceededDecline({ inviteId }), - (cause) => - Message.FailedDecline({ - inviteId, - toast: failureToast(cause, "friendly", { - ...inviteNotFound("This invite may have already been revoked or expired."), - ConnectInviteInvalidStateError: { - title: "Cannot decline", - description: "This invite is no longer in a declinable state.", - }, - ConnectWorkspaceNotFoundError: { - title: "Workspace not found", - description: "This invite is not bound to a workspace.", - }, - }), - }), - ), + catchCommon((reason) => Message.FailedDecline({ inviteId, reason })), + ) + }, }) // INIT @@ -130,7 +106,8 @@ const requestFor = (model: Model, shared: Shared): Return => { model: modifyFields(model, { requestedFor: () => organizationId, listVersion: () => version, - invites: () => [], + // Another organization's invites are not this one's stale data. + invites: () => Invites.Loading(), }), commands: [ListIncomingInvites({ organizationId, version })], } @@ -141,7 +118,7 @@ export const init = (_route: unknown, shared: Shared): Return => { requestedFor: null, listVersion: 0, - invites: [], + invites: Invites.Idle(), hostOrganizations: [], acceptingIds: [], decliningIds: [], @@ -157,12 +134,18 @@ export const sharedChanged = requestFor const without = (ids: ReadonlyArray<ConnectInviteId>, id: ConnectInviteId) => ids.filter((other) => other !== id) -/** Legacy invalidates the `connectInvites:incoming:<org>` reactivity key after a successful mutation. */ -const refetch = (model: Model, settled: Model): Return => { - if (model.requestedFor === null) return { model: settled } +/** + * Lists again under a new version, keeping loaded rows on screen. Legacy invalidates the + * `connectInvites:incoming:<org>` reactivity key after a successful mutation. + */ +const refetch = (model: Model): Return => { + if (model.requestedFor === null) return { model } const version = model.listVersion + 1 return { - model: modifyFields(settled, { listVersion: () => version }), + model: modifyFields(model, { + listVersion: () => version, + invites: (invites) => Option.getOrElse(AsyncData.revalidateOrLoad(invites), () => invites), + }), commands: [ListIncomingInvites({ organizationId: model.requestedFor, version })], } } @@ -175,16 +158,25 @@ const isCurrentList = (model: Model, organizationId: OrganizationId, version: nu const isBusy = (model: Model, inviteId: ConnectInviteId) => model.acceptingIds.includes(inviteId) || model.decliningIds.includes(inviteId) +const notFound = (description: string) => ({ title: "Invite not found", description }) +const workspaceNotFound = (description: string) => ({ title: "Workspace not found", description }) + export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { SucceededListInvites: ({ organizationId, version, invites }) => isCurrentList(model, organizationId, version) - ? { model: modifyFields(model, { invites: () => invites }) } + ? { model: modifyFields(model, { invites: () => Invites.Success({ data: invites }) }) } : { model }, - FailedListInvites: ({ organizationId, version }) => + // A failed refetch keeps the rows it had (`Stale`); a failed first load shows the error. + FailedListInvites: ({ organizationId, version, reason }) => isCurrentList(model, organizationId, version) - ? { model: modifyFields(model, { invites: () => [] }) } + ? { + model: modifyFields(model, { + invites: (invites) => AsyncData.settle(invites, Result.fail(reason)), + }), + } : { model }, + ClickedRetryListInvites: () => refetch(model), UpdatedHostOrganizations: ({ organizations }) => ({ model: modifyFields(model, { hostOrganizations: () => organizations }), }), @@ -198,12 +190,27 @@ export const update = (model: Model, message: Message, shared: Shared): Return = commands: [AcceptInvite({ inviteId, guestOrganizationId: shared.organization.id })], }, SucceededAccept: ({ inviteId }) => ({ - ...refetch(model, modifyFields(model, { acceptingIds: (ids) => without(ids, inviteId) })), + ...refetch(modifyFields(model, { acceptingIds: (ids) => without(ids, inviteId) })), outMessage: PageOutMessage.RequestedToast({ toast: successToast("Channel connected") }), }), - FailedAccept: ({ inviteId, toast }) => ({ + FailedAccept: ({ inviteId, reason }) => ({ model: modifyFields(model, { acceptingIds: (ids) => without(ids, inviteId) }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: notFound("This invite may have been revoked or expired."), + InvalidState: { + title: "Cannot accept", + description: "This invite is no longer in an acceptable state.", + }, + WorkspaceNotFound: workspaceNotFound("The target workspace could not be found."), + AlreadyShared: { + title: "Already shared", + description: "This channel is already shared with that organization.", + }, + }), + ), + }), }), ClickedDecline: ({ inviteId }) => isBusy(model, inviteId) @@ -215,12 +222,23 @@ export const update = (model: Model, message: Message, shared: Shared): Return = commands: [DeclineInvite({ inviteId })], }, SucceededDecline: ({ inviteId }) => ({ - ...refetch(model, modifyFields(model, { decliningIds: (ids) => without(ids, inviteId) })), + ...refetch(modifyFields(model, { decliningIds: (ids) => without(ids, inviteId) })), outMessage: PageOutMessage.RequestedToast({ toast: successToast("Invite declined") }), }), - FailedDecline: ({ inviteId, toast }) => ({ + FailedDecline: ({ inviteId, reason }) => ({ model: modifyFields(model, { decliningIds: (ids) => without(ids, inviteId) }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: notFound("This invite may have already been revoked or expired."), + InvalidState: { + title: "Cannot decline", + description: "This invite is no longer in a declinable state.", + }, + WorkspaceNotFound: workspaceNotFound("This invite is not bound to a workspace."), + }), + ), + }), }), GotInteractionMessage: ({ message }) => interaction.fold(model, message), }) diff --git a/apps/web-foldkit/src/page/settings/connect-invites/view.ts b/apps/web-foldkit/src/page/settings/connect-invites/view.ts index 82f17d4aa..2ef1a5632 100644 --- a/apps/web-foldkit/src/page/settings/connect-invites/view.ts +++ b/apps/web-foldkit/src/page/settings/connect-invites/view.ts @@ -1,14 +1,17 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" import { getConnectInviteStatusBadge } from "~/lib/connect-shared-channels" -import { IconConnect } from "../../../icons" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" +import { IconConnect, IconWarning } from "../../../icons" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" import { card, cardHeader } from "../../../ui/card" import { emptyState } from "../../../ui/empty-state" +import { loader } from "../../../ui/loader" import type { PageViewInputs } from "../../contract" import { Message } from "./message" -import { acceptTarget, declineTarget, interaction } from "./update" +import { acceptTarget, declineTarget, interaction, invitesOf } from "./update" import type { Invite, Model } from "./model" /** Port of `routes/_app/$orgSlug/settings/connect-invites.tsx`. */ @@ -86,9 +89,101 @@ const inviteRow = (h: HtmlBuilder<Message>, model: Model, invite: Invite): Html ) } +const retryButton = (h: HtmlBuilder<Message>, reason: CommonReason): ReadonlyArray<Html> => + isRetryableReason(reason) + ? [ + button(h, { intent: "secondary", size: "sm", onPress: Message.ClickedRetryListInvites() }, [ + "Try again", + ]), + ] + : [] + +const loadingState = (h: HtmlBuilder<Message>): Html => + h.div( + [h.Class("flex h-64 items-center justify-center gap-3 text-muted-fg")], + [loader(h, { className: "size-6" }), h.span([h.Class("text-sm")], ["Loading invitations..."])], + ) + +/** The first load failed: nothing to show but the reason. */ +const failedState = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return emptyState(h, { + icon: (className) => IconWarning(h, { className }), + title: "Couldn't load connect invitations", + description: message.description ?? message.title, + action: retryButton(h, reason), + className: "h-64", + }) +} + +/** A refetch failed: the last loaded rows stay, with the reason above them. */ +const staleNotice = (h: HtmlBuilder<Message>, reason: CommonReason): Html => + h.div( + [ + h.Class("flex items-center justify-between gap-3 border-border border-b px-4 py-3"), + h.Role("alert"), + ], + [ + h.p([h.Class("text-danger text-sm")], [`Couldn't refresh: ${commonMessage(reason).title}`]), + ...retryButton(h, reason), + ], + ) + +const invitesTable = (h: HtmlBuilder<Message>, model: Model, invites: ReadonlyArray<Invite>): Html => + h.div( + [h.Class("overflow-x-auto")], + [ + h.table( + [h.Class("w-full min-w-full")], + [ + h.thead( + [h.Class("border-border border-b bg-bg")], + [ + h.tr( + [], + [ + headerCell(h, "From", "left"), + headerCell(h, "Status", "left"), + headerCell(h, "Received", "left"), + headerCell(h, "Actions", "right"), + ], + ), + ], + ), + h.tbody( + [h.Class("divide-y divide-border")], + [ + ...invites.filter((invite) => invite.status === "pending"), + ...invites.filter((invite) => invite.status !== "pending"), + ].map((invite) => inviteRow(h, model, invite)), + ), + ], + ), + ], + ) + +const loadedBody = (h: HtmlBuilder<Message>, model: Model, invites: ReadonlyArray<Invite>): Html => + invites.length === 0 + ? emptyState(h, { + icon: (className) => IconConnect(h, { className }), + title: "No connect invitations", + description: "When another organization invites you to share a channel, it will appear here.", + className: "h-64", + }) + : invitesTable(h, model, invites) + +const body = (h: HtmlBuilder<Message>, model: Model): Html => + AsyncData.match(model.invites, { + onIdle: () => loadingState(h), + onLoading: () => loadingState(h), + onFailure: (reason) => failedState(h, reason), + onRefreshing: (invites) => loadedBody(h, model, invites), + onStale: ({ error, data }) => h.div([], [staleNotice(h, error), loadedBody(h, model, data)]), + onSuccess: (invites) => loadedBody(h, model, invites), + }) + export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => { - const pendingInvites = model.invites.filter((invite) => invite.status === "pending") - const otherInvites = model.invites.filter((invite) => invite.status !== "pending") + const pendingCount = invitesOf(model).filter((invite) => invite.status === "pending").length return h.div( [h.Class("flex flex-col gap-6 px-4 lg:px-8")], [ @@ -101,13 +196,8 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, [h.Class("flex items-center gap-2")], [ h.h2([h.Class("font-semibold text-fg text-lg")], ["Connect invitations"]), - ...(pendingInvites.length > 0 - ? [ - badge(h, { intent: "secondary" }, [ - `${pendingInvites.length}`, - " pending", - ]), - ] + ...(pendingCount > 0 + ? [badge(h, { intent: "secondary" }, [`${pendingCount}`, " pending"])] : []), ], ), @@ -118,44 +208,7 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, ], ), ]), - model.invites.length === 0 - ? emptyState(h, { - icon: (className) => IconConnect(h, { className }), - title: "No connect invitations", - description: - "When another organization invites you to share a channel, it will appear here.", - className: "h-64", - }) - : h.div( - [h.Class("overflow-x-auto")], - [ - h.table( - [h.Class("w-full min-w-full")], - [ - h.thead( - [h.Class("border-border border-b bg-bg")], - [ - h.tr( - [], - [ - headerCell(h, "From", "left"), - headerCell(h, "Status", "left"), - headerCell(h, "Received", "left"), - headerCell(h, "Actions", "right"), - ], - ), - ], - ), - h.tbody( - [h.Class("divide-y divide-border")], - [...pendingInvites, ...otherInvites].map((invite) => - inviteRow(h, model, invite), - ), - ), - ], - ), - ], - ), + body(h, model), ]), ], ) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/message.ts b/apps/web-foldkit/src/page/settings/custom-emojis/message.ts index d3c2f7dec..ac51d3a6b 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/message.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/message.ts @@ -2,17 +2,16 @@ import { CustomEmojiId } from "@hazel/schema" import { Schema } from "effect" import { File } from "foldkit/file" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" -import * as Modal from "../../../ui/modal" -import { Emoji, RestoreTarget } from "./model" +import * as ConfirmAction from "../../shared/confirm-action" +import * as DropZone from "../../shared/drop-zone" +import { CreateFailure, DeleteFailure, Emoji, RestoreFailure, RestoreTarget } from "./model" export const Message = defineMessageUnion({ UpdatedEmojis: { emojis: Schema.Array(Emoji) }, ClickedBrowse: { inputId: Schema.String }, CompletedOpenPicker: {}, SelectedFiles: { files: Schema.Array(File) }, - EnteredDropZone: {}, - LeftDropZone: {}, + GotDropZoneMessage: { message: DropZone.Message }, CreatedPreview: { file: File, previewUrl: Schema.String }, CompletedFocusName: {}, ChangedEmojiName: { value: Schema.String }, @@ -21,15 +20,15 @@ export const Message = defineMessageUnion({ ClickedSaveEmoji: {}, SucceededCreateEmoji: { name: Schema.String, previewUrl: Schema.String }, FoundDeletedEmoji: { target: RestoreTarget }, - FailedCreateEmoji: { toast: ToastRequest }, + FailedCreateEmoji: { reason: CreateFailure }, ClickedConfirmRestore: {}, SucceededRestoreEmoji: { name: Schema.String, previewUrl: Schema.NullOr(Schema.String) }, - FailedRestoreEmoji: {}, + FailedRestoreEmoji: { reason: RestoreFailure }, ClickedDeleteEmoji: { id: CustomEmojiId, name: Schema.String }, ClickedConfirmDelete: {}, SucceededDeleteEmoji: { name: Schema.String }, - FailedDeleteEmoji: {}, - GotDeleteModalMessage: { message: Modal.Message }, - GotRestoreModalMessage: { message: Modal.Message }, + FailedDeleteEmoji: { reason: DeleteFailure }, + GotDeleteConfirmMessage: { message: ConfirmAction.Message }, + GotRestoreConfirmMessage: { message: ConfirmAction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts b/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts index 44fc50249..8bf49e2d5 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts @@ -1,8 +1,17 @@ +import { Option } from "effect" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { IconArrowPath, IconWarning } from "../../../icons" import { button } from "../../../ui/button" -import { dialog, dialogClose, dialogDescription, dialogFooter, dialogHeader, dialogTitle } from "../../../ui/dialog" +import { + dialog, + dialogClose, + dialogDescription, + dialogFooter, + dialogHeader, + dialogTitle, +} from "../../../ui/dialog" import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" import { Message } from "./message" import type { Model } from "./model" @@ -11,8 +20,10 @@ import type { Model } from "./model" * inside, so React Aria renders a dialog within the ModalContent's dialog. */ -const toDeleteMessage = (message: Modal.Message) => Message.GotDeleteModalMessage({ message }) -const toRestoreMessage = (message: Modal.Message) => Message.GotRestoreModalMessage({ message }) +const toDeleteMessage = (message: Modal.Message) => + Message.GotDeleteConfirmMessage({ message: ConfirmAction.toModalMessage(message) }) +const toRestoreMessage = (message: Modal.Message) => + Message.GotRestoreConfirmMessage({ message: ConfirmAction.toModalMessage(message) }) const innerTitleId = (modalId: string) => `${modalId}-inner-title` @@ -34,10 +45,12 @@ const footer = ( ): Html => dialogFooter(h, [dialogClose(h, closeAttributes, ["Cancel"], "secondary"), confirm]) export const deleteEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html => { - const id = model.deleteModal.id + const id = model.deleteConfirm.modal.id + const target = Option.getOrNull(ConfirmAction.targetOf(model.deleteConfirm)) + const isDeleting = ConfirmAction.isRunning(model.deleteConfirm) return h.submodel({ slotId: id, - model: model.deleteModal, + model: model.deleteConfirm.modal, view: Modal.view, viewInputs: { toTrigger: (_attributes, overlay) => overlay, @@ -48,14 +61,22 @@ export const deleteEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html => dialogTitle(h, { id: innerTitleId(id) }, "Delete custom emoji"), dialogDescription(h, [ "Are you sure you want to delete ", - h.strong([], [":", model.deleteTarget?.name ?? "", ":"]), + h.strong([], [":", target?.name ?? "", ":"]), "? Messages that use this emoji will show the text code instead.", ]), ]), footer( h, closeAttributes, - button(h, { intent: "danger", onPress: Message.ClickedConfirmDelete() }, ["Delete emoji"]), + button( + h, + { + intent: "danger", + isPending: isDeleting, + onPress: Message.ClickedConfirmDelete(), + }, + [isDeleting ? "Deleting..." : "Delete emoji"], + ), ), ]), ], @@ -78,12 +99,13 @@ const imageTile = (h: HtmlBuilder<Message>, src: string, label: string): Html => ) export const restoreEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html => { - const id = model.restoreModal.id - const target = model.restoreTarget + const id = model.restoreConfirm.modal.id + const target = Option.getOrNull(ConfirmAction.targetOf(model.restoreConfirm)) + const isRestoring = ConfirmAction.isRunning(model.restoreConfirm) const imagesAreDifferent = target !== null && target.imageUrl !== target.newImageUrl return h.submodel({ slotId: id, - model: model.restoreModal, + model: model.restoreConfirm.modal, view: Modal.view, viewInputs: { toTrigger: (_attributes, overlay) => overlay, @@ -113,9 +135,15 @@ export const restoreEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html = footer( h, closeAttributes, - button(h, { intent: "primary", onPress: Message.ClickedConfirmRestore() }, [ - "Restore with new image", - ]), + button( + h, + { + intent: "primary", + isPending: isRestoring, + onPress: Message.ClickedConfirmRestore(), + }, + [isRestoring ? "Restoring..." : "Restore with new image"], + ), ), ]), ], diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/model.ts b/apps/web-foldkit/src/page/settings/custom-emojis/model.ts index f5a1bee3c..69287f162 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/model.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/model.ts @@ -1,7 +1,11 @@ import { CustomEmojiId } from "@hazel/schema" import { Schema } from "effect" +import { Field } from "foldkit/fieldValidation" import { File } from "foldkit/file" -import * as Modal from "../../../ui/modal" +import { CommonReason } from "~/lib/error-messages" +import * as ConfirmAction from "../../shared/confirm-action" +import * as DropZone from "../../shared/drop-zone" +import { UploadProblem } from "../upload" export const Emoji = Schema.Struct({ id: CustomEmojiId, @@ -17,8 +21,7 @@ export type Emoji = typeof Emoji.Type export const Draft = Schema.Struct({ file: File, previewUrl: Schema.String, - name: Schema.String, - nameError: Schema.NullOr(Schema.String), + name: Field(Schema.String), }) export type Draft = typeof Draft.Type @@ -33,15 +36,27 @@ export const RestoreTarget = Schema.Struct({ }) export type RestoreTarget = typeof RestoreTarget.Type +/** Why creating failed: the upload's problems, an active emoji already has the name, or a shared reason. */ +export const CreateFailure = Schema.Union([UploadProblem, Schema.Literal("NameTaken"), CommonReason]) +export type CreateFailure = typeof CreateFailure.Type + +/** Why restoring failed, beyond the shared reasons. */ +export const RestoreFailure = Schema.Union([Schema.Literals(["NotFound", "NameTaken"]), CommonReason]) +export type RestoreFailure = typeof RestoreFailure.Type + +/** Why deleting failed, beyond the shared reasons. */ +export const DeleteFailure = Schema.Union([Schema.Literal("NotFound"), CommonReason]) +export type DeleteFailure = typeof DeleteFailure.Type + export const Model = Schema.Struct({ /** `null` while the live query loads (the legacy skeleton table). */ emojis: Schema.NullOr(Schema.Array(Emoji)), draft: Schema.NullOr(Draft), - isSaving: Schema.Boolean, - isDropTarget: Schema.Boolean, - deleteTarget: Schema.NullOr(DeleteTarget), - restoreTarget: Schema.NullOr(RestoreTarget), - deleteModal: Modal.Model, - restoreModal: Modal.Model, + /** The draft's upload and create are running. */ + isCreating: Schema.Boolean, + dropZone: DropZone.Model, + deleteConfirm: ConfirmAction.Model(DeleteTarget), + /** Asked when the draft's name belongs to a deleted emoji. */ + restoreConfirm: ConfirmAction.Model(RestoreTarget), }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts b/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts index 68d7d8716..482cc0097 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts @@ -128,12 +128,13 @@ describe("delete", () => { Scene.expect(Scene.within(deleteDialog, Scene.text(":shipit:"))).toExist(), Scene.click(Scene.role("button", { name: "Delete emoji" })), Scene.Command.expectExact(DeleteCustomEmoji({ emojiId: shipit.id, name: "shipit" })), - Scene.expect(deleteDialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.expect(Scene.within(deleteDialog, Scene.role("button", { name: "Deleting..." }))).toExist(), Scene.Command.resolve(DeleteCustomEmoji, Message.SucceededDeleteEmoji({ name: "shipit" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ toast: successToast("Emoji :shipit: deleted") }), ), + Scene.expect(deleteDialog).toBeAbsent(), + Scene.Mount.expectEnded(Modal.PortalModal), ) }) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts b/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts index fc2e2a9b9..d647c6fd3 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { CustomEmojiId, OrganizationId, OrganizationMemberId } from "@hazel/schema" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../out-message" @@ -15,15 +15,11 @@ import { RevokeEmojiPreview, CreateCustomEmoji, } from "./update" -import { errorToast, successToast } from "../../../data/actions" -import { - failureToastFixture, - makeShared, - organizationId, - storyUpdate, - userId, -} from "../../../test/pages-fixtures" +import { errorToast, reasonToast, successToast } from "../../../data/actions" +import { makeShared, organizationId, storyUpdate, userId } from "../../../test/pages-fixtures" import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" +import type { CreateFailure } from "./model" import { sharedDefaults } from "../../test-shared" /** Update-loop tests for custom emojis: names, file checks and the delete confirmation. */ @@ -52,10 +48,14 @@ describe("emoji names", () => { }) test("validation matches the legacy rules", () => { - expect(validateEmojiName("")).toBe("Name is required") - expect(validateEmojiName("a".repeat(65))).toBe("Name must be 64 characters or less") - expect(validateEmojiName("Ship It")).toBe("Only lowercase letters, numbers, hyphens, and underscores") - expect(validateEmojiName("ship-it_2")).toBeNull() + const errorsOf = (name: string) => { + const field = validateEmojiName(name) + return field._tag === "Invalid" ? field.errors : [] + } + expect(errorsOf("")).toEqual(["Name is required"]) + expect(errorsOf("a".repeat(65))).toEqual(["Name must be 64 characters or less"]) + expect(errorsOf("Ship It")).toEqual(["Only lowercase letters, numbers, hyphens, and underscores"]) + expect(validateEmojiName("ship-it_2")).toEqual({ _tag: "Valid", value: "ship-it_2" }) }) }) @@ -85,35 +85,50 @@ describe("file selection", () => { Message.CreatedPreview({ file, previewUrl: "blob:1" }), shared, ).model - expect(drafted.draft).toMatchObject({ name: "ship_it", nameError: null }) + expect(drafted.draft?.name).toEqual({ _tag: "Valid", value: "ship_it" }) const edited = update(drafted, Message.ChangedEmojiName({ value: "Ship It" }), shared).model - expect(edited.draft).toMatchObject({ - name: "ship it", - nameError: "Only lowercase letters, numbers, hyphens, and underscores", + expect(edited.draft?.name).toEqual({ + _tag: "Invalid", + value: "ship it", + errors: ["Only lowercase letters, numbers, hyphens, and underscores"], }) }) }) describe("delete confirmation", () => { - test("confirming closes the modal and deletes the target", () => { + test("confirming deletes the target, and the dialog closes once it succeeds", () => { const open = update( init().model, Message.ClickedDeleteEmoji({ id: shipit, name: "shipit" }), shared, ).model - expect(open.deleteModal.isOpen).toBe(true) + expect(open.deleteConfirm.modal.isOpen).toBe(true) const confirmed = update(open, Message.ClickedConfirmDelete(), shared) - expect(confirmed.model).toMatchObject({ deleteTarget: null, deleteModal: { isOpen: false } }) + expect(confirmed.model.deleteConfirm.state).toEqual({ + _tag: "Running", + target: { id: shipit, name: "shipit" }, + }) expect(confirmed.commands?.[0]).toMatchObject({ name: "DeleteCustomEmoji", args: { emojiId: shipit }, }) + expect(update(confirmed.model, Message.ClickedConfirmDelete(), shared).commands ?? []).toHaveLength(0) + const deleted = update(confirmed.model, Message.SucceededDeleteEmoji({ name: "shipit" }), shared) + expect(deleted.model.deleteConfirm).toMatchObject({ + state: { _tag: "Closed" }, + modal: { isOpen: false }, + }) }) - test("failure toasts the legacy message", () => { - expect(update(init().model, Message.FailedDeleteEmoji(), shared).outMessage).toMatchObject({ - toast: { intent: "error", title: "Failed to delete emoji" }, + test("a missing emoji names itself; other failures get the shared copy", () => { + expect( + update(init().model, Message.FailedDeleteEmoji({ reason: "NotFound" }), shared).outMessage, + ).toMatchObject({ + toast: errorToast("Failed to delete emoji", "It may have already been deleted."), }) + expect( + update(init().model, Message.FailedDeleteEmoji({ reason: "Offline" }), shared).outMessage, + ).toMatchObject({ toast: reasonToast("Offline") }) }) }) @@ -139,12 +154,14 @@ describe("delete failure", () => { message(Message.ClickedDeleteEmoji({ id: shipit, name: "shipit" })), message(Message.ClickedConfirmDelete()), Command.expectExact(DeleteCustomEmoji({ emojiId: shipit, name: "shipit" })), - Command.resolve(DeleteCustomEmoji, Message.FailedDeleteEmoji()), + Command.resolve(DeleteCustomEmoji, Message.FailedDeleteEmoji({ reason: "NotFound" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to delete emoji", description: null }, + toast: errorToast("Failed to delete emoji", "It may have already been deleted."), }), ), + // The dialog stays open so the user can retry or cancel. + model((current) => expect(current.deleteConfirm.state._tag).toBe("Confirming")), ) }) }) @@ -162,6 +179,8 @@ const restoreTarget = { imageUrl: "https://cdn/old.png", newImageUrl: "https://cdn/new.png", } +/** The draft's name belongs to a deleted emoji: the restore dialog asks. */ +const asking = { ...drafted, restoreConfirm: ConfirmAction.ask(drafted.restoreConfirm, restoreTarget) } describe("upload draft", () => { test("a picked file previews, focuses the name, and a second pick revokes the first preview", () => { @@ -202,7 +221,13 @@ describe("save", () => { message(Message.ChangedEmojiName({ value: "" })), message(Message.ClickedSaveEmoji()), Command.expectNone(), - model((current) => expect(current.draft?.nameError).toBe("Name is required")), + model((current) => + expect(current.draft?.name).toEqual({ + _tag: "Invalid", + value: "", + errors: ["Name is required"], + }), + ), ) }) @@ -217,7 +242,7 @@ describe("save", () => { given(drafted), message(Message.ClickedSaveEmoji()), Command.expectExact(CreateCustomEmoji), - model((current) => expect(current.isSaving).toBe(true)), + model((current) => expect(current.isCreating).toBe(true)), Command.resolve( CreateCustomEmoji, Message.SucceededCreateEmoji({ name: "ship_it", previewUrl: "blob:1" }), @@ -226,14 +251,14 @@ describe("save", () => { PageOutMessage.RequestedToast({ toast: successToast("Emoji :ship_it: created") }), ), Command.resolve(RevokeEmojiPreview({ previewUrl: "blob:1" }), Message.CompletedRevokePreview()), - model((current) => expect(current).toMatchObject({ isSaving: false, draft: null })), + model((current) => expect(current).toMatchObject({ isCreating: false, draft: null })), ) }) test("Save while a save is in flight, or without a signed-in user, sends nothing", () => { story( owner, - given({ ...drafted, isSaving: true }), + given({ ...drafted, isCreating: true }), message(Message.ClickedSaveEmoji()), Command.expectNone(), ) @@ -274,13 +299,27 @@ describe("save", () => { owner, given(drafted), message(Message.ClickedSaveEmoji()), - Command.resolve(CreateCustomEmoji, Message.FailedCreateEmoji({ toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Command.resolve(CreateCustomEmoji, Message.FailedCreateEmoji({ reason: "Unexpected" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Unexpected") })), model((current) => - expect(current).toMatchObject({ isSaving: false, draft: { name: "ship_it" } }), + expect(current).toMatchObject({ isCreating: false, draft: { name: { value: "ship_it" } } }), ), ) }) + + test("each create failure gets its own copy; only a name conflict says the name is taken", () => { + const toastFor = (reason: CreateFailure) => + update(drafted, Message.FailedCreateEmoji({ reason }), makeShared()).outMessage + expect(toastFor("NameTaken")).toMatchObject({ + toast: errorToast("Failed to create emoji", "The name is already taken. Please try another."), + }) + expect(toastFor("Offline")).toMatchObject({ toast: reasonToast("Offline") }) + expect(toastFor("SignedOut")).toMatchObject({ toast: reasonToast("SignedOut") }) + expect(toastFor("TooLarge")).toMatchObject({ + toast: errorToast("File too large", "File size must be less than 256KB"), + }) + expect(toastFor("NotConfigured")).toMatchObject({ toast: { title: "Configuration error" } }) + }) }) describe("restore a deleted emoji", () => { @@ -292,7 +331,10 @@ describe("restore a deleted emoji", () => { Command.resolve(CreateCustomEmoji, Message.FoundDeletedEmoji({ target: restoreTarget })), expectNoOutMessage(), model((current) => - expect(current).toMatchObject({ isSaving: false, restoreModal: { isOpen: true } }), + expect(current).toMatchObject({ + isCreating: false, + restoreConfirm: { state: { _tag: "Confirming" }, modal: { isOpen: true } }, + }), ), message(Message.ClickedConfirmRestore()), Command.expectExact( @@ -306,11 +348,7 @@ describe("restore a deleted emoji", () => { }), ), model((current) => - expect(current).toMatchObject({ - isSaving: true, - restoreTarget: null, - restoreModal: { isOpen: false }, - }), + expect(current.restoreConfirm.state).toEqual({ _tag: "Running", target: restoreTarget }), ), Command.resolve( RestoreCustomEmoji, @@ -320,12 +358,21 @@ describe("restore a deleted emoji", () => { PageOutMessage.RequestedToast({ toast: successToast("Emoji :ship_it: restored") }), ), Command.resolve(RevokeEmojiPreview, Message.CompletedRevokePreview()), - model((current) => expect(current.draft).toBeNull()), + model((current) => { + expect(current.draft).toBeNull() + expect(current.restoreConfirm).toMatchObject({ + state: { _tag: "Closed" }, + modal: { isOpen: false }, + }) + }), ) }) test("a second confirm while a restore runs sends nothing", () => { - const restoring = { ...drafted, restoreTarget, isSaving: true } + const restoring = { + ...drafted, + restoreConfirm: Option.getOrThrow(ConfirmAction.confirm(asking.restoreConfirm))[0], + } expect(update(restoring, Message.ClickedConfirmRestore(), makeShared()).commands ?? []).toHaveLength( 0, ) @@ -334,22 +381,35 @@ describe("restore a deleted emoji", () => { test("a failed restore keeps the draft and toasts", () => { story( owner, - given({ ...drafted, restoreTarget, restoreModal: Modal.open(drafted.restoreModal).model }), + given(asking), message(Message.ClickedConfirmRestore()), - Command.resolve(RestoreCustomEmoji, Message.FailedRestoreEmoji()), - expectOutMessage(PageOutMessage.RequestedToast({ toast: errorToast("Failed to restore emoji") })), - model((current) => - expect(current).toMatchObject({ isSaving: false, draft: { previewUrl: "blob:1" } }), + Command.resolve(RestoreCustomEmoji, Message.FailedRestoreEmoji({ reason: "NameTaken" })), + expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast( + "Failed to restore emoji", + "Another emoji already uses this name. Please try another.", + ), + }), ), + model((current) => { + expect(current.draft?.previewUrl).toBe("blob:1") + // Back to asking, so the user can retry or cancel. + expect(current.restoreConfirm.state._tag).toBe("Confirming") + }), ) }) test("dismissing the restore dialog forgets the target, so a late confirm sends nothing", () => { story( owner, - given({ ...drafted, restoreTarget, restoreModal: Modal.open(drafted.restoreModal).model }), - message(Message.GotRestoreModalMessage({ message: Modal.Message.ClickedClose() })), - model((current) => expect(current.restoreTarget).toBeNull()), + given(asking), + message( + Message.GotRestoreConfirmMessage({ + message: ConfirmAction.toModalMessage(Modal.Message.ClickedClose()), + }), + ), + model((current) => expect(current.restoreConfirm.state._tag).toBe("Closed")), message(Message.ClickedConfirmRestore()), Command.expectNone(), ) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts index 73f61dbf5..47684ea8b 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts @@ -1,26 +1,32 @@ -import { CustomEmojiDeletedExistsError } from "@hazel/domain/rpc" import { CustomEmojiId, OrganizationId, UserId } from "@hazel/schema" -import { Array, Cause, Effect, Option, Schema } from "effect" +import { Array, Effect, Option, Schema } from "effect" import { Command, Update } from "foldkit" import * as Dom from "foldkit/dom" +import { makeRules, Rule, validate } from "foldkit/fieldValidation" import { File } from "foldkit/file" import { modifyFields } from "foldkit/struct" import { createCustomEmojiAction, deleteCustomEmojiAction, restoreCustomEmojiAction } from "~/db/actions" -import type { ToastRequest } from "../../../overlay/toasts" -import * as Modal from "../../../ui/modal" +import { failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../../data/actions" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, successToast } from "../../../data/actions" -import { ALLOWED_EMOJI_TYPES, MAX_EMOJI_SIZE, publicUrlOf, uploadFile } from "../upload" +import * as ConfirmAction from "../../shared/confirm-action" +import * as DropZone from "../../shared/drop-zone" +import { UploadProblem, UploadTarget, uploadFile, uploadProblemMessage, validateUploadFile } from "../upload" import { Message } from "./message" -import type { Model } from "./model" +import type { + CreateFailure, + DeleteFailure, + DeleteTarget, + Model, + RestoreFailure, + RestoreTarget, +} from "./model" type Return = PageReturn<Model, Message> export const EMOJI_NAME_ID = "custom-emoji-name" -const NAME_PATTERN = /^[a-z0-9_-]+$/ - export const generateEmojiName = (filename: string): string => filename .replace(/\.[^.]+$/, "") @@ -30,12 +36,16 @@ export const generateEmojiName = (filename: string): string => .replace(/^_|_$/g, "") .slice(0, 64) -export const validateEmojiName = (name: string): string | null => { - if (!name) return "Name is required" - if (name.length > 64) return "Name must be 64 characters or less" - if (!NAME_PATTERN.test(name)) return "Only lowercase letters, numbers, hyphens, and underscores" - return null -} +/** The legacy shortcode rules and copy. */ +export const emojiNameRules = makeRules({ + required: "Name is required", + rules: [ + Rule.maxLength(64, "Name must be 64 characters or less"), + Rule.pattern(/^[a-z0-9_-]+$/, "Only lowercase letters, numbers, hyphens, and underscores"), + ], +}) + +export const validateEmojiName = validate(emojiNameRules) // COMMAND @@ -43,6 +53,7 @@ export const OpenEmojiPicker = Command.define("OpenEmojiPicker", { args: { inputId: Schema.String }, messages: [Message.CompletedOpenPicker], execute: ({ inputId }) => + // Best effort: a missing input just means nothing opens. Dom.clickElement(`#${inputId}`).pipe(Effect.ignore, Effect.as(Message.CompletedOpenPicker())), }) @@ -55,6 +66,7 @@ export const CreateEmojiPreview = Command.define("CreateEmojiPreview", { export const FocusEmojiName = Command.define("FocusEmojiName", { messages: [Message.CompletedFocusName], + // Best effort: focus is a convenience. execute: Dom.focus(`#${EMOJI_NAME_ID}-input`).pipe( Effect.ignore, Effect.as(Message.CompletedFocusName()), @@ -71,12 +83,6 @@ export const RevokeEmojiPreview = Command.define("RevokeEmojiPreview", { }), }) -const deletedExistsErrorOf = (cause: Cause.Cause<unknown>) => - Array.findFirst( - cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error), - (error): error is CustomEmojiDeletedExistsError => error instanceof CustomEmojiDeletedExistsError, - ) - /** Uploads the draft's file and creates the emoji; `previewUrl` names the draft the result belongs to. */ export const CreateCustomEmoji = Command.define("CreateCustomEmoji", { args: { @@ -87,47 +93,33 @@ export const CreateCustomEmoji = Command.define("CreateCustomEmoji", { createdBy: UserId, }, messages: [Message.SucceededCreateEmoji, Message.FoundDeletedEmoji, Message.FailedCreateEmoji], - execute: ({ organizationId, name, file, previewUrl, createdBy }) => - Effect.gen(function* () { - const key = yield* uploadFile({ type: "custom-emoji", organizationId }, file) - const imageUrl = publicUrlOf(key) - if (imageUrl === null) { - return Message.FailedCreateEmoji({ - toast: errorToast( - "Configuration error", - "Image upload is not configured. Please contact support.", - ), - }) - } - const exit = yield* Effect.exit( - createCustomEmojiAction({ organizationId, name, imageUrl, createdBy }), - ) - if (exit._tag === "Success") return Message.SucceededCreateEmoji({ name, previewUrl }) - return Option.match(deletedExistsErrorOf(exit.cause), { - onSome: (error) => - Message.FoundDeletedEmoji({ - target: { - id: error.customEmojiId, - name: error.name, - imageUrl: error.imageUrl, - newImageUrl: imageUrl, - }, + execute: ({ organizationId, name, file, previewUrl, createdBy }) => { + const failed = (reason: CreateFailure) => Effect.succeed(Message.FailedCreateEmoji({ reason })) + return uploadFile(UploadTarget.CustomEmoji({ organizationId }), file).pipe( + Effect.flatMap((imageUrl) => + createCustomEmojiAction({ organizationId, name, imageUrl, createdBy }).pipe( + Effect.as(Message.SucceededCreateEmoji({ name, previewUrl })), + Effect.catchTags({ + // A deleted emoji holds the name: offer to restore it with the new image. + CustomEmojiDeletedExistsError: (error) => + Effect.succeed( + Message.FoundDeletedEmoji({ + target: { + id: error.customEmojiId, + name: error.name, + imageUrl: error.imageUrl, + newImageUrl: imageUrl, + }, + }), + ), + CustomEmojiNameConflictError: () => failed("NameTaken"), }), - onNone: () => - Message.FailedCreateEmoji({ - toast: errorToast( - "Failed to create emoji", - "The name may already be taken. Please try another.", - ), - }), - }) - }).pipe( - Effect.catchTag("UploadFailedError", (error) => - Effect.succeed( - Message.FailedCreateEmoji({ toast: errorToast(error.message, error.description) }), ), ), - ), + Effect.catchTags({ UploadFailedError: (error) => failed(error.problem) }), + catchCommon((reason) => Message.FailedCreateEmoji({ reason })), + ) + }, }) export const RestoreCustomEmoji = Command.define("RestoreCustomEmoji", { @@ -141,13 +133,17 @@ export const RestoreCustomEmoji = Command.define("RestoreCustomEmoji", { previewUrl: Schema.NullOr(Schema.String), }, messages: [Message.SucceededRestoreEmoji, Message.FailedRestoreEmoji], - execute: ({ previewUrl, ...args }) => - restoreCustomEmojiAction(args).pipe( - Effect.match({ - onSuccess: () => Message.SucceededRestoreEmoji({ name: args.name, previewUrl }), - onFailure: () => Message.FailedRestoreEmoji(), + execute: ({ previewUrl, ...args }) => { + const failed = (reason: RestoreFailure) => Effect.succeed(Message.FailedRestoreEmoji({ reason })) + return restoreCustomEmojiAction(args).pipe( + Effect.as(Message.SucceededRestoreEmoji({ name: args.name, previewUrl })), + Effect.catchTags({ + CustomEmojiNotFoundError: () => failed("NotFound"), + CustomEmojiNameConflictError: () => failed("NameTaken"), }), - ), + catchCommon((reason) => Message.FailedRestoreEmoji({ reason })), + ) + }, }) export const DeleteCustomEmoji = Command.define("DeleteCustomEmoji", { @@ -155,10 +151,12 @@ export const DeleteCustomEmoji = Command.define("DeleteCustomEmoji", { messages: [Message.SucceededDeleteEmoji, Message.FailedDeleteEmoji], execute: ({ emojiId, name }) => deleteCustomEmojiAction({ emojiId }).pipe( - Effect.match({ - onSuccess: () => Message.SucceededDeleteEmoji({ name }), - onFailure: () => Message.FailedDeleteEmoji(), + Effect.as(Message.SucceededDeleteEmoji({ name })), + Effect.catchTags({ + CustomEmojiNotFoundError: () => + Effect.succeed(Message.FailedDeleteEmoji({ reason: "NotFound" satisfies DeleteFailure })), }), + catchCommon((reason) => Message.FailedDeleteEmoji({ reason })), ), }) @@ -168,73 +166,98 @@ export const init = (): Return => ({ model: { emojis: null, draft: null, - isSaving: false, - isDropTarget: false, - deleteTarget: null, - restoreTarget: null, - deleteModal: Modal.init("delete-custom-emoji"), - restoreModal: Modal.init("restore-custom-emoji"), + isCreating: false, + dropZone: DropZone.init(), + deleteConfirm: ConfirmAction.init<DeleteTarget>("delete-custom-emoji"), + restoreConfirm: ConfirmAction.init<RestoreTarget>("restore-custom-emoji"), }, }) +// COPY + +const isUploadProblem = Schema.is(UploadProblem) + +const createFailureMessage = (reason: CreateFailure): UserErrorMessage => + isUploadProblem(reason) + ? uploadProblemMessage("emoji", reason) + : failureMessage(reason, { + NameTaken: { + title: "Failed to create emoji", + description: "The name is already taken. Please try another.", + }, + }) + +const restoreFailureMessage = (reason: RestoreFailure): UserErrorMessage => + failureMessage(reason, { + NotFound: { title: "Failed to restore emoji", description: "The deleted emoji no longer exists." }, + NameTaken: { + title: "Failed to restore emoji", + description: "Another emoji already uses this name. Please try another.", + }, + }) + +const deleteFailureMessage = (reason: DeleteFailure): UserErrorMessage => + failureMessage(reason, { + NotFound: { title: "Failed to delete emoji", description: "It may have already been deleted." }, + }) + // UPDATE -const toast = (model: Model, request: ToastRequest, commands: Return["commands"] = []): Return => ({ - model, - commands, - outMessage: PageOutMessage.RequestedToast({ toast: request }), -}) +/** A create or a confirmed restore is running; the draft is locked meanwhile. */ +export const isBusy = (model: Model) => model.isCreating || ConfirmAction.isRunning(model.restoreConfirm) /** `handleCancel`: drop the draft and revoke its preview URL. */ -const clearDraft = (model: Model): { model: Model; commands: NonNullable<Return["commands"]> } => ({ +type Cleared = { readonly model: Model; readonly commands?: Return["commands"] } + +const clearDraft = (model: Model): Cleared => ({ model: modifyFields(model, { draft: () => null }), commands: model.draft === null ? [] : [RevokeEmojiPreview({ previewUrl: model.draft.previewUrl })], }) -const modalFold = ( - key: "deleteModal" | "restoreModal", - toParentMessage: (message: Modal.Message) => Message, -) => { - const target = key === "deleteModal" ? "deleteTarget" : "restoreTarget" - return { - read: (model: Model) => Option.some(model[key]), - // A closed dialog forgets its target (legacy `onOpenChange(false)`). - write: (model: Model, modal: Modal.Model): Model => ({ - ...model, - [key]: modal, - ...(modal.isOpen ? {} : { [target]: null }), - }), - toParentMessage, - } -} -const deleteModal = modalFold("deleteModal", (message) => Message.GotDeleteModalMessage({ message })) -const restoreModal = modalFold("restoreModal", (message) => Message.GotRestoreModalMessage({ message })) -const foldDeleteModal = Update.foldChild({ update: Modal.update, ...deleteModal }) -const openDeleteModal = Update.foldChildStep({ update: Modal.open, ...deleteModal }) -const closeDeleteModal = Update.foldChildStep({ update: Modal.close, ...deleteModal }) -const foldRestoreModal = Update.foldChild({ update: Modal.update, ...restoreModal }) -const openRestoreModal = Update.foldChildStep({ update: Modal.open, ...restoreModal }) -const closeRestoreModal = Update.foldChildStep({ update: Modal.close, ...restoreModal }) - /** Clears the draft only if it is still the one the result belongs to. */ -const clearDraftOf = (model: Model, previewUrl: string | null): Return => - model.draft !== null && model.draft.previewUrl === previewUrl - ? clearDraft(model) - : { model } +const clearDraftOf = (model: Model, previewUrl: string | null): Cleared => + model.draft !== null && model.draft.previewUrl === previewUrl ? clearDraft(model) : { model } + +const foldDeleteConfirm = Update.foldChild({ + update: (child: ConfirmAction.Model<DeleteTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(child, message), + read: (model: Model) => Option.some(model.deleteConfirm), + write: (model: Model, deleteConfirm: ConfirmAction.Model<DeleteTarget>): Model => ({ + ...model, + deleteConfirm, + }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotDeleteConfirmMessage({ message }), +}) + +const foldRestoreConfirm = Update.foldChild({ + update: (child: ConfirmAction.Model<RestoreTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(child, message), + read: (model: Model) => Option.some(model.restoreConfirm), + write: (model: Model, restoreConfirm: ConfirmAction.Model<RestoreTarget>): Model => ({ + ...model, + restoreConfirm, + }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotRestoreConfirmMessage({ message }), +}) -const withCommands = (result: Return, commands: Return["commands"]): Return => ({ - ...result, - commands: [...(result.commands ?? []), ...(commands ?? [])], +const foldDropZone = Update.foldChild({ + update: DropZone.update, + read: (model: Model) => Option.some(model.dropZone), + write: (model: Model, dropZone: DropZone.Model): Model => ({ ...model, dropZone }), + toParentMessage: (message: DropZone.Message) => Message.GotDropZoneMessage({ message }), }) +/** The file checks `uploadFile` runs, up front so a bad file never becomes a draft. */ const selectFile = (model: Model, file: globalThis.File): Return => { - if (!ALLOWED_EMOJI_TYPES.includes(file.type)) { - return toast(model, errorToast("Invalid file type", "Please select a PNG, GIF, or WebP image")) - } - if (file.size > MAX_EMOJI_SIZE) { - return toast(model, errorToast("File too large", "Emoji images must be under 256KB")) - } - return { model, commands: [CreateEmojiPreview({ file })] } + const problem = validateUploadFile("emoji", file) + return problem === null + ? { model, commands: [CreateEmojiPreview({ file })] } + : { + model, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(uploadProblemMessage("emoji", problem)), + }), + } } export const update = (model: Model, message: Message, shared: Shared): Return => @@ -243,19 +266,23 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedBrowse: ({ inputId }) => ({ model, commands: [OpenEmojiPicker({ inputId })] }), CompletedOpenPicker: () => ({ model }), // A file picked while a save runs would replace the draft being saved. - SelectedFiles: ({ files }) => - Option.match(model.isSaving ? Option.none() : Array.head(files), { - onNone: () => ({ model: modifyFields(model, { isDropTarget: () => false }) }), - onSome: (file) => selectFile(modifyFields(model, { isDropTarget: () => false }), file), - }), - EnteredDropZone: () => ({ model: modifyFields(model, { isDropTarget: () => true }) }), - LeftDropZone: () => ({ model: modifyFields(model, { isDropTarget: () => false }) }), + SelectedFiles: ({ files }) => { + const dropped = modifyFields(model, { dropZone: () => DropZone.reset() }) + return Option.match(isBusy(model) ? Option.none() : Array.head(files), { + onNone: () => ({ model: dropped }), + onSome: (file) => selectFile(dropped, file), + }) + }, + GotDropZoneMessage: ({ message: child }) => foldDropZone(model, child), CreatedPreview: ({ file, previewUrl }) => { - if (model.isSaving) return { model, commands: [RevokeEmojiPreview({ previewUrl })] } - const name = generateEmojiName(file.name) + if (isBusy(model)) return { model, commands: [RevokeEmojiPreview({ previewUrl })] } return { model: modifyFields(model, { - draft: () => ({ file, previewUrl, name, nameError: name ? null : "Name is required" }), + draft: () => ({ + file, + previewUrl, + name: validateEmojiName(generateEmojiName(file.name)), + }), }), commands: [ ...(model.draft === null @@ -266,31 +293,31 @@ export const update = (model: Model, message: Message, shared: Shared): Return = } }, CompletedFocusName: () => ({ model }), - ChangedEmojiName: ({ value }) => { - const name = value.toLowerCase() - return { - model: modifyFields(model, { - draft: (draft) => - draft === null ? null : { ...draft, name, nameError: validateEmojiName(name) }, - }), - } - }, + ChangedEmojiName: ({ value }) => ({ + model: modifyFields(model, { + draft: (draft) => + draft === null + ? null + : modifyFields(draft, { name: () => validateEmojiName(value.toLowerCase()) }), + }), + }), ClickedCancelUpload: () => clearDraft(model), CompletedRevokePreview: () => ({ model }), ClickedSaveEmoji: () => { const draft = model.draft if (draft === null) return { model } - const error = validateEmojiName(draft.name) - if (error) - return { model: modifyFields(model, { draft: () => modifyFields(draft, { nameError: () => error }) }) } - if (shared.organization === null || shared.currentUser === null || model.isSaving) - return { model } + const name = validateEmojiName(draft.name.value) + if (name._tag !== "Valid") + return { + model: modifyFields(model, { draft: () => modifyFields(draft, { name: () => name }) }), + } + if (shared.organization === null || shared.currentUser === null || isBusy(model)) return { model } return { - model: modifyFields(model, { isSaving: () => true }), + model: modifyFields(model, { isCreating: () => true }), commands: [ CreateCustomEmoji({ organizationId: shared.organization.id, - name: draft.name, + name: name.value, file: draft.file, previewUrl: draft.previewUrl, createdBy: shared.currentUser.id, @@ -298,52 +325,76 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ], } }, - SucceededCreateEmoji: ({ name, previewUrl }) => { - const cleared = clearDraftOf(modifyFields(model, { isSaving: () => false }), previewUrl) - return toast(cleared.model, successToast(`Emoji :${name}: created`), cleared.commands) - }, - FoundDeletedEmoji: ({ target }) => - openRestoreModal(modifyFields(model, { isSaving: () => false, restoreTarget: () => target })), - FailedCreateEmoji: ({ toast: request }) => - toast(modifyFields(model, { isSaving: () => false }), request), + SucceededCreateEmoji: ({ name, previewUrl }) => + Update.withOutMessage( + clearDraftOf(modifyFields(model, { isCreating: () => false }), previewUrl), + PageOutMessage.RequestedToast({ toast: successToast(`Emoji :${name}: created`) }), + ), + FoundDeletedEmoji: ({ target }) => ({ + model: modifyFields(model, { + isCreating: () => false, + restoreConfirm: (confirm) => ConfirmAction.ask(confirm, target), + }), + }), + FailedCreateEmoji: ({ reason }) => + Update.withOutMessage( + { model: modifyFields(model, { isCreating: () => false }) }, + PageOutMessage.RequestedToast({ toast: errorToastOf(createFailureMessage(reason)) }), + ), ClickedConfirmRestore: () => { - const target = model.restoreTarget - if ( - target === null || - shared.organization === null || - shared.currentUser === null || - model.isSaving - ) { - return { model } - } - return withCommands(closeRestoreModal(modifyFields(model, { isSaving: () => true })), [ - RestoreCustomEmoji({ - emojiId: target.id, - organizationId: shared.organization.id, - name: target.name, - imageUrl: target.newImageUrl, - createdBy: shared.currentUser.id, - previewUrl: model.draft?.previewUrl ?? null, + const organization = shared.organization + const user = shared.currentUser + if (organization === null || user === null || model.isCreating) return { model } + return Option.match(ConfirmAction.confirm(model.restoreConfirm), { + onNone: () => ({ model }), + onSome: ([restoreConfirm, target]) => ({ + model: modifyFields(model, { restoreConfirm: () => restoreConfirm }), + commands: [ + RestoreCustomEmoji({ + emojiId: target.id, + organizationId: organization.id, + name: target.name, + imageUrl: target.newImageUrl, + createdBy: user.id, + previewUrl: model.draft?.previewUrl ?? null, + }), + ], }), - ]) - }, - SucceededRestoreEmoji: ({ name, previewUrl }) => { - const cleared = clearDraftOf(modifyFields(model, { isSaving: () => false }), previewUrl) - return toast(cleared.model, successToast(`Emoji :${name}: restored`), cleared.commands) - }, - FailedRestoreEmoji: () => - toast(modifyFields(model, { isSaving: () => false }), errorToast("Failed to restore emoji")), - ClickedDeleteEmoji: ({ id, name }) => - openDeleteModal(modifyFields(model, { deleteTarget: () => ({ id, name }) })), - ClickedConfirmDelete: () => { - const target = model.deleteTarget - if (target === null) return { model } - return withCommands(closeDeleteModal(model), [ - DeleteCustomEmoji({ emojiId: target.id, name: target.name }), - ]) + }) }, - SucceededDeleteEmoji: ({ name }) => toast(model, successToast(`Emoji :${name}: deleted`)), - FailedDeleteEmoji: () => toast(model, errorToast("Failed to delete emoji")), - GotDeleteModalMessage: ({ message: child }) => foldDeleteModal(model, child), - GotRestoreModalMessage: ({ message: child }) => foldRestoreModal(model, child), + SucceededRestoreEmoji: ({ name, previewUrl }) => + Update.withOutMessage( + clearDraftOf(modifyFields(model, { restoreConfirm: ConfirmAction.close }), previewUrl), + PageOutMessage.RequestedToast({ toast: successToast(`Emoji :${name}: restored`) }), + ), + FailedRestoreEmoji: ({ reason }) => + Update.withOutMessage( + { model: modifyFields(model, { restoreConfirm: ConfirmAction.failed }) }, + PageOutMessage.RequestedToast({ toast: errorToastOf(restoreFailureMessage(reason)) }), + ), + ClickedDeleteEmoji: ({ id, name }) => ({ + model: modifyFields(model, { + deleteConfirm: (confirm) => ConfirmAction.ask(confirm, { id, name }), + }), + }), + ClickedConfirmDelete: () => + Option.match(ConfirmAction.confirm(model.deleteConfirm), { + onNone: () => ({ model }), + onSome: ([deleteConfirm, target]) => ({ + model: modifyFields(model, { deleteConfirm: () => deleteConfirm }), + commands: [DeleteCustomEmoji({ emojiId: target.id, name: target.name })], + }), + }), + SucceededDeleteEmoji: ({ name }) => + Update.withOutMessage( + { model: modifyFields(model, { deleteConfirm: ConfirmAction.close }) }, + PageOutMessage.RequestedToast({ toast: successToast(`Emoji :${name}: deleted`) }), + ), + FailedDeleteEmoji: ({ reason }) => + Update.withOutMessage( + { model: modifyFields(model, { deleteConfirm: ConfirmAction.failed }) }, + PageOutMessage.RequestedToast({ toast: errorToastOf(deleteFailureMessage(reason)) }), + ), + GotDeleteConfirmMessage: ({ message: child }) => foldDeleteConfirm(model, child), + GotRestoreConfirmMessage: ({ message: child }) => foldRestoreConfirm(model, child), }) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts index d6414596b..723962a37 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/view.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/view.ts @@ -11,11 +11,12 @@ import { inputGroup } from "../../../ui/input" import { textField } from "../../../ui/text-field" import type { PageViewInputs, Shared } from "../../contract" import { formatDistanceToNow } from "../format-distance" +import * as DropZone from "../../shared/drop-zone" import { ALLOWED_EMOJI_TYPES } from "../upload" import { deleteEmojiModal, restoreEmojiModal } from "./modals" import { Message } from "./message" import type { Draft, Emoji, Model } from "./model" -import { EMOJI_NAME_ID } from "./update" +import { EMOJI_NAME_ID, isBusy } from "./update" /** Port of `routes/_app/$orgSlug/settings/custom-emojis.tsx`. */ @@ -39,17 +40,18 @@ const fileInput = (h: HtmlBuilder<Message>, id: string): Html => const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): Html => h.div( [ - h.Class("rounded-lg focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"), + h.Class( + "rounded-lg focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2", + ), h.DataAttribute("rac", ""), - ...(model.isDropTarget ? [h.DataAttribute("drop-target", "true")] : []), ...(isDisabled ? [h.DataAttribute("disabled", "true")] - : [ - h.AllowDrop(), - h.OnDragEnter(Message.EnteredDropZone()), - h.OnDragLeave(Message.LeftDropZone()), - h.OnDropFiles((files) => Message.SelectedFiles({ files })), - ]), + : DropZone.zoneAttributes( + h, + model.dropZone, + (message) => Message.GotDropZoneMessage({ message }), + (files) => Message.SelectedFiles({ files }), + )), ], [ h.div( @@ -69,7 +71,7 @@ const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): H h.Class( cx( "flex w-full cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 transition-colors", - model.isDropTarget + model.dropZone.isOver ? "border-primary bg-primary/5" : "border-border bg-secondary/20 hover:border-muted-fg/40 hover:bg-secondary/40", isDisabled && "pointer-events-none opacity-50", @@ -86,7 +88,10 @@ const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): H h.div( [h.Class("text-center")], [ - h.p([h.Class("font-medium text-fg text-sm")], ["Drop an image or click to browse"]), + h.p( + [h.Class("font-medium text-fg text-sm")], + ["Drop an image or click to browse"], + ), h.p([h.Class("mt-0.5 text-muted-fg text-xs")], ["PNG, GIF, or WebP · Max 256KB"]), ], ), @@ -97,8 +102,9 @@ const dropZone = (h: HtmlBuilder<Message>, model: Model, isDisabled: boolean): H ) const draftForm = (h: HtmlBuilder<Message>, model: Model, draft: Draft): Html => { - const busy = model.isSaving - const isInvalid = draft.nameError !== null + const busy = isBusy(model) + const nameError = draft.name._tag === "Invalid" ? draft.name.errors[0] : null + const isInvalid = nameError !== null return h.div( [h.Class("rounded-lg border border-border bg-bg p-4")], [ @@ -111,7 +117,13 @@ const draftForm = (h: HtmlBuilder<Message>, model: Model, draft: Draft): Html => "flex size-24 shrink-0 items-center justify-center self-center overflow-hidden rounded-lg bg-secondary sm:self-start", ), ], - [h.img([h.Src(draft.previewUrl), h.Alt("Emoji preview"), h.Class("size-24 object-contain")])], + [ + h.img([ + h.Src(draft.previewUrl), + h.Alt("Emoji preview"), + h.Class("size-24 object-contain"), + ]), + ], ), h.div( [h.Class("flex min-w-0 flex-1 flex-col gap-3")], @@ -120,7 +132,7 @@ const draftForm = (h: HtmlBuilder<Message>, model: Model, draft: Draft): Html => h, { id: EMOJI_NAME_ID, - value: draft.name, + value: draft.name.value, onInput: (value) => Message.ChangedEmojiName({ value }), isInvalid, isDisabled: busy, @@ -128,24 +140,30 @@ const draftForm = (h: HtmlBuilder<Message>, model: Model, draft: Draft): Html => (parts) => [ parts.label(["Name"]), inputGroup(h, { isDisabled: busy, isInvalid }, [ - h.span([h.DataAttribute("slot", "text"), h.Class("text-muted-fg")], [":"]), + h.span( + [h.DataAttribute("slot", "text"), h.Class("text-muted-fg")], + [":"], + ), parts.input({ placeholder: "emoji_name", attributes: [h.Attribute("maxlength", "64")], }), - h.span([h.DataAttribute("slot", "text"), h.Class("text-muted-fg")], [":"]), + h.span( + [h.DataAttribute("slot", "text"), h.Class("text-muted-fg")], + [":"], + ), ]), h.div( [h.Class("mt-1 flex items-center justify-between gap-2")], [ - draft.nameError !== null - ? parts.fieldError([draft.nameError]) + nameError !== null + ? parts.fieldError([nameError]) : parts.description([ "Lowercase letters, numbers, hyphens, and underscores", ]), h.span( [h.Class("shrink-0 text-muted-fg text-xs tabular-nums")], - [`${draft.name.length}/64`], + [`${draft.name.value.length}/64`], ), ], ), @@ -169,7 +187,7 @@ const draftForm = (h: HtmlBuilder<Message>, model: Model, draft: Draft): Html => { intent: "primary", size: "sm", - isDisabled: busy || isInvalid || !draft.name, + isDisabled: busy || isInvalid || !draft.name.value, onPress: Message.ClickedSaveEmoji(), }, [busy ? "Saving..." : "Save Emoji"], @@ -227,7 +245,10 @@ const skeletonRow = (h: HtmlBuilder<Message>, index: number): Html => pulse(h, "h-4 w-24"), pulse(h, "h-4 w-28"), pulse(h, "h-4 w-20"), - h.td([h.Class("px-4 py-4 text-right")], [h.div([h.Class("size-8 animate-pulse rounded-sm bg-secondary")])]), + h.td( + [h.Class("px-4 py-4 text-right")], + [h.div([h.Class("size-8 animate-pulse rounded-sm bg-secondary")])], + ), ], ) @@ -240,10 +261,18 @@ const emojiRow = (h: HtmlBuilder<Message>, emoji: Emoji, isAdmin: boolean, nowMs [h.Class("px-4 py-4")], [h.img([h.Src(emoji.imageUrl), h.Alt(emoji.name), h.Class("size-8 rounded object-contain")])], ), - h.td([h.Class("px-4 py-4")], [h.span([h.Class("font-medium text-fg text-sm")], [":", emoji.name, ":"])]), h.td( [h.Class("px-4 py-4")], - [h.span([h.Class("text-muted-fg text-sm")], [emoji.creatorFirstName, " ", emoji.creatorLastName])], + [h.span([h.Class("font-medium text-fg text-sm")], [":", emoji.name, ":"])], + ), + h.td( + [h.Class("px-4 py-4")], + [ + h.span( + [h.Class("text-muted-fg text-sm")], + [emoji.creatorFirstName, " ", emoji.creatorLastName], + ), + ], ), h.td( [h.Class("px-4 py-4")], @@ -270,7 +299,10 @@ const emojiRow = (h: HtmlBuilder<Message>, emoji: Emoji, isAdmin: boolean, nowMs { intent: "danger", size: "sq-sm", - onPress: Message.ClickedDeleteEmoji({ id: emoji.id, name: emoji.name }), + onPress: Message.ClickedDeleteEmoji({ + id: emoji.id, + name: emoji.name, + }), }, [IconTrash(h, { attributes: { "data-slot": "icon" } })], ), @@ -283,7 +315,11 @@ const emojiRow = (h: HtmlBuilder<Message>, emoji: Emoji, isAdmin: boolean, nowMs ) const emojiList = (h: HtmlBuilder<Message>, model: Model, isAdmin: boolean, nowMs: number): Html => { - if (model.emojis === null) return table(h, [0, 1, 2, 3, 4].map((index) => skeletonRow(h, index))) + if (model.emojis === null) + return table( + h, + [0, 1, 2, 3, 4].map((index) => skeletonRow(h, index)), + ) if (model.emojis.length === 0) { return emptyState(h, { icon: (className) => IconEmoji1(h, { className }), @@ -331,10 +367,17 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, [ IconEmoji1(h, { className: "size-5 text-muted-fg" }), h.h2([h.Class("font-semibold text-fg text-lg")], ["Custom Emojis"]), - badge(h, { intent: "secondary" }, [`${count}`, " emoji", ...(count !== 1 ? ["s"] : [])]), + badge(h, { intent: "secondary" }, [ + `${count}`, + " emoji", + ...(count !== 1 ? ["s"] : []), + ]), ], ), - h.p([h.Class("text-muted-fg text-sm")], ["Upload and manage custom emojis for your workspace."]), + h.p( + [h.Class("text-muted-fg text-sm")], + ["Upload and manage custom emojis for your workspace."], + ), ], ), ]), diff --git a/apps/web-foldkit/src/page/settings/general/delete-workspace.ts b/apps/web-foldkit/src/page/settings/general/delete-workspace.ts index 0ff5e5c08..fa261cc77 100644 --- a/apps/web-foldkit/src/page/settings/general/delete-workspace.ts +++ b/apps/web-foldkit/src/page/settings/general/delete-workspace.ts @@ -4,6 +4,7 @@ import { button } from "../../../ui/button" import { dialogBody, dialogFooter, dialogHeader, dialogTitle } from "../../../ui/dialog" import * as Field from "../../../ui/field" import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" import { textField } from "../../../ui/text-field" import type { Organization } from "../../../session" import { Message } from "./message" @@ -11,12 +12,18 @@ import type { Model } from "./model" /** Port of `components/modals/delete-workspace-modal.tsx` (a page-local controlled modal). */ -const toDeleteModalMessage = (message: Modal.Message) => Message.GotDeleteModalMessage({ message }) +const toDeleteModalMessage = (message: Modal.Message) => + Message.GotDeleteConfirmMessage({ message: ConfirmAction.toModalMessage(message) }) -export const deleteWorkspaceModal = (h: HtmlBuilder<Message>, model: Model, organization: Organization): Html => - h.submodel({ +export const deleteWorkspaceModal = ( + h: HtmlBuilder<Message>, + model: Model, + organization: Organization, +): Html => { + const isDeleting = ConfirmAction.isRunning(model.deleteConfirm) + return h.submodel({ slotId: "delete-workspace-modal", - model: model.deleteModal, + model: model.deleteConfirm.modal, view: Modal.view, viewInputs: { // Controlled: no trigger, only the overlay while open. @@ -27,10 +34,18 @@ export const deleteWorkspaceModal = (h: HtmlBuilder<Message>, model: Model, orga [h.Class("flex items-center gap-2")], [ h.div( - [h.Class("flex size-10 items-center justify-center rounded-full bg-danger/10")], + [ + h.Class( + "flex size-10 items-center justify-center rounded-full bg-danger/10", + ), + ], [IconWarning(h, { className: "size-5 text-danger" })], ), - dialogTitle(h, { id: Modal.titleId(model.deleteModal.id) }, "Delete workspace"), + dialogTitle( + h, + { id: Modal.titleId(model.deleteConfirm.modal.id) }, + "Delete workspace", + ), ], ), Field.description(h, {}, [ @@ -67,18 +82,18 @@ export const deleteWorkspaceModal = (h: HtmlBuilder<Message>, model: Model, orga dialogFooter(h, [ button( h, - { intent: "outline", isDisabled: model.isDeleting, onPress: Message.ClickedCancelDelete() }, + { intent: "outline", isDisabled: isDeleting, onPress: Message.ClickedCancelDelete() }, ["Cancel"], ), button( h, { intent: "danger", - isDisabled: model.confirmationText !== organization.name || model.isDeleting, - isPending: model.isDeleting, + isDisabled: model.confirmationText !== organization.name || isDeleting, + isPending: isDeleting, onPress: Message.ClickedConfirmDelete(), }, - [model.isDeleting ? "Deleting..." : "Delete workspace"], + [isDeleting ? "Deleting..." : "Delete workspace"], ), ]), ], @@ -86,3 +101,4 @@ export const deleteWorkspaceModal = (h: HtmlBuilder<Message>, model: Model, orga }, toParentMessage: toDeleteModalMessage, }) +} diff --git a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts index 3fccbb006..59f966563 100644 --- a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts +++ b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts @@ -2,7 +2,6 @@ import * as Scene from "foldkit/scene" import { describe, test } from "vitest" import { - failureToastFixture, makeShared, memberWithRole, organizationId, @@ -10,7 +9,7 @@ import { portalModalMounted, } from "../../../test/pages-fixtures" import { PageOutMessage } from "../../out-message" -import { successToast } from "../../../data/actions" +import { errorToast, reasonToast, successToast } from "../../../data/actions" import * as Modal from "../../../ui/modal" import { Message } from "./message" import { DeleteOrganization, init, SetPublicMode, update, UpdateOrganizationName } from "./update" @@ -51,11 +50,8 @@ describe("organization name", () => { Scene.type(nameInput, "Hazel HQ"), Scene.keydown(nameInput, "Enter"), Scene.expectHandled(), - Scene.Command.resolve( - UpdateOrganizationName, - Message.FailedUpdateName({ toast: failureToastFixture }), - ), - Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Scene.Command.resolve(UpdateOrganizationName, Message.FailedUpdateName({ reason: "Offline" })), + Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Offline") })), Scene.expect(Scene.role("button", { name: "Save Changes" })).toBeEnabled(), ) }) @@ -156,9 +152,13 @@ describe("delete workspace", () => { Scene.click(confirmDelete), Scene.Command.resolve( DeleteOrganization, - Message.FailedDeleteWorkspace({ toast: failureToastFixture }), + Message.FailedDeleteWorkspace({ reason: "NotPermitted" }), + ), + Scene.expectOutMessage( + PageOutMessage.RequestedToast({ + toast: errorToast("Unauthorized", "You don't have permission to delete this workspace."), + }), ), - Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), Scene.expect(confirmInput).toHaveValue("Hazel Labs"), Scene.expect(confirmDelete).toBeEnabled(), ) diff --git a/apps/web-foldkit/src/page/settings/general/message.ts b/apps/web-foldkit/src/page/settings/general/message.ts index 02e00932c..11af44abe 100644 --- a/apps/web-foldkit/src/page/settings/general/message.ts +++ b/apps/web-foldkit/src/page/settings/general/message.ts @@ -1,8 +1,14 @@ import { Schema } from "effect" import { File } from "foldkit/file" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" -import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" +import { + CopyTarget, + DeleteWorkspaceFailure, + RenameFailure, + SetPublicModeFailure, + UploadLogoFailure, +} from "./model" export const Message = defineMessageUnion({ ResolvedOrigin: { origin: Schema.String }, @@ -11,23 +17,24 @@ export const Message = defineMessageUnion({ SubmittedName: {}, ClickedCancelName: {}, SucceededUpdateName: {}, - FailedUpdateName: { toast: ToastRequest }, + FailedUpdateName: { reason: RenameFailure }, ClickedLogo: {}, CompletedOpenLogoPicker: {}, SelectedLogo: { files: Schema.Array(File) }, SucceededUploadLogo: {}, - FailedUploadLogo: { toast: ToastRequest }, + FailedUploadLogo: { reason: UploadLogoFailure }, ToggledPublicMode: { isPublic: Schema.Boolean }, SucceededSetPublicMode: { isPublic: Schema.Boolean }, - FailedSetPublicMode: { toast: ToastRequest }, - ClickedCopy: { text: Schema.String, successTitle: Schema.String, failureTitle: Schema.String }, - CompletedCopy: { toast: ToastRequest }, + FailedSetPublicMode: { reason: SetPublicModeFailure }, + ClickedCopy: { target: CopyTarget, text: Schema.String }, + SucceededCopy: { target: CopyTarget }, + FailedCopy: { target: CopyTarget, reason: Schema.Literal("ClipboardDenied") }, ClickedDeleteWorkspace: {}, - GotDeleteModalMessage: { message: Modal.Message }, + GotDeleteConfirmMessage: { message: ConfirmAction.Message }, ChangedConfirmation: { value: Schema.String }, ClickedCancelDelete: {}, ClickedConfirmDelete: {}, SucceededDeleteWorkspace: {}, - FailedDeleteWorkspace: { toast: ToastRequest }, + FailedDeleteWorkspace: { reason: DeleteWorkspaceFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/general/model.ts b/apps/web-foldkit/src/page/settings/general/model.ts index 2bda553c1..ee6687b8a 100644 --- a/apps/web-foldkit/src/page/settings/general/model.ts +++ b/apps/web-foldkit/src/page/settings/general/model.ts @@ -1,5 +1,47 @@ +import { OrganizationId } from "@hazel/schema" import { Schema } from "effect" -import * as Modal from "../../../ui/modal" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" +import * as ConfirmAction from "../../shared/confirm-action" +import { UploadProblem } from "../upload" + +/** Saving the name, uploading the logo and toggling public invites run independently. */ + +export const NameSave = defineTaggedUnion({ Idle: {}, Saving: {} }) +export type NameSave = typeof NameSave.Type + +export const LogoUpload = defineTaggedUnion({ Idle: {}, Uploading: {} }) +export type LogoUpload = typeof LogoUpload.Type + +export const PublicModeSave = defineTaggedUnion({ Idle: {}, Saving: { isPublic: Schema.Boolean } }) +export type PublicModeSave = typeof PublicModeSave.Type + +/** The workspace the delete dialog asks about. */ +export const DeleteTarget = Schema.Struct({ organizationId: OrganizationId }) +export type DeleteTarget = typeof DeleteTarget.Type + +/** Which URL a Copy button copies (the toast names it). */ +export const CopyTarget = Schema.Literals(["WorkspaceUrl", "InviteLink"]) +export type CopyTarget = typeof CopyTarget.Type + +/** `organization.update` failures, beyond the shared reasons. */ +const UpdateOrganizationFailure = Schema.Literals(["NotFound", "SlugTaken"]) + +export const RenameFailure = Schema.Union([UpdateOrganizationFailure, CommonReason]) +export type RenameFailure = typeof RenameFailure.Type + +export const UploadLogoFailure = Schema.Union([UploadProblem, UpdateOrganizationFailure, CommonReason]) +export type UploadLogoFailure = typeof UploadLogoFailure.Type + +export const SetPublicModeFailure = Schema.Union([Schema.Literal("NotFound"), CommonReason]) +export type SetPublicModeFailure = typeof SetPublicModeFailure.Type + +/** `organization.delete` answers `UnauthorizedError` to a non-owner: `NotPermitted`. */ +export const DeleteWorkspaceFailure = Schema.Union([ + Schema.Literals(["NotFound", "NotPermitted"]), + CommonReason, +]) +export type DeleteWorkspaceFailure = typeof DeleteWorkspaceFailure.Type export const Model = Schema.Struct({ orgSlug: Schema.String, @@ -10,11 +52,11 @@ export const Model = Schema.Struct({ /** The server name the draft was last synced from (legacy render-time adjustment). */ syncedName: Schema.NullOr(Schema.String), isPublic: Schema.Boolean, - isSavingName: Schema.Boolean, - isTogglingPublic: Schema.Boolean, - isUploading: Schema.Boolean, - deleteModal: Modal.Model, + nameSave: NameSave, + logoUpload: LogoUpload, + publicModeSave: PublicModeSave, + deleteConfirm: ConfirmAction.Model(DeleteTarget), + /** The workspace name typed into the delete dialog; cleared whenever the dialog closes. */ confirmationText: Schema.String, - isDeleting: Schema.Boolean, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/settings/general/story.test.ts b/apps/web-foldkit/src/page/settings/general/story.test.ts index 387befcff..0c321687f 100644 --- a/apps/web-foldkit/src/page/settings/general/story.test.ts +++ b/apps/web-foldkit/src/page/settings/general/story.test.ts @@ -1,18 +1,15 @@ // @vitest-environment jsdom import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" +import { Option } from "effect" import { describe, expect, test } from "vitest" -import { errorToast, successToast } from "../../../data/actions" -import { - failureToastFixture, - makeShared, - organization, - organizationId, - storyUpdate, -} from "../../../test/pages-fixtures" +import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" +import { errorToast, reasonToast, successToast } from "../../../data/actions" +import { makeShared, organization, organizationId, storyUpdate } from "../../../test/pages-fixtures" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import type { Model } from "./model" +import { LogoUpload, type Model, NameSave, PublicModeSave, type UploadLogoFailure } from "./model" import { DeleteOrganization, init, @@ -71,25 +68,33 @@ describe("organization name", () => { expectOutMessage( PageOutMessage.RequestedToast({ toast: successToast("Organization name updated") }), ), - model((current) => expect(current.isSavingName).toBe(false)), + model((current) => expect(current.nameSave).toEqual(NameSave.Idle())), ) const saving = update( - { ...initial, name: "Hazel HQ", isSavingName: true }, + { ...initial, name: "Hazel HQ", nameSave: NameSave.Saving() }, Message.SubmittedName(), owner, ) expect(saving.commands ?? []).toHaveLength(0) }) - test("a failed save clears the saving flag and forwards the failure toast", () => { + test("a failed save goes back to idle, keeps the draft and toasts the reason's copy", () => { story<Model, Message, PageOutMessage>( storyUpdate(update, owner), given({ ...initial, name: "Hazel HQ" }), message(Message.SubmittedName()), - Command.resolve(UpdateOrganizationName, Message.FailedUpdateName({ toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), - model((current) => expect(current).toMatchObject({ isSavingName: false, name: "Hazel HQ" })), + Command.resolve(UpdateOrganizationName, Message.FailedUpdateName({ reason: "Offline" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Offline") })), + model((current) => + expect(current).toMatchObject({ nameSave: NameSave.Idle(), name: "Hazel HQ" }), + ), ) + expect( + update({ ...initial, name: "Hazel HQ" }, Message.FailedUpdateName({ reason: "SlugTaken" }), owner) + .outMessage, + ).toMatchObject({ + toast: errorToast("Slug already exists", "This organization slug is already taken."), + }) }) test("a new server name replaces the draft, an unchanged one keeps it", () => { @@ -113,35 +118,46 @@ describe("logo", () => { message(Message.SelectedLogo({ files: [logo] })), // Matched by name: structural equality cannot compare a jsdom File. Command.expectExact(UploadLogo), - model((current) => expect(current.isUploading).toBe(true)), + model((current) => expect(current.logoUpload).toEqual(LogoUpload.Uploading())), Command.resolve(UploadLogo, Message.SucceededUploadLogo()), expectOutMessage( PageOutMessage.RequestedToast({ toast: successToast("Organization logo updated") }), ), - model((current) => expect(current.isUploading).toBe(false)), + model((current) => expect(current.logoUpload).toEqual(LogoUpload.Idle())), ) }) test("a file picked while an upload runs is ignored", () => { - const uploading: Model = { ...initial, isUploading: true } + const uploading: Model = { ...initial, logoUpload: LogoUpload.Uploading() } expect(update(uploading, Message.SelectedLogo({ files: [logo] }), owner).commands ?? []).toHaveLength( 0, ) }) - test("a failed upload clears the uploading flag and toasts; an empty pick does nothing", () => { - const toast = errorToast("Upload failed", "Failed to update organization. Please try again.") + test("a failed upload goes back to idle and toasts; an empty pick does nothing", () => { + const toast = errorToast("File too large", "File size must be less than 5MB") story<Model, Message, PageOutMessage>( storyUpdate(update, owner), given(initial), message(Message.SelectedLogo({ files: [] })), Command.expectNone(), message(Message.SelectedLogo({ files: [logo] })), - Command.resolve(UploadLogo, Message.FailedUploadLogo({ toast })), + Command.resolve(UploadLogo, Message.FailedUploadLogo({ reason: "TooLarge" })), expectOutMessage(PageOutMessage.RequestedToast({ toast })), - model((current) => expect(current.isUploading).toBe(false)), + model((current) => expect(current.logoUpload).toEqual(LogoUpload.Idle())), ) }) + + test("upload problems and organization errors each get their own copy, not a generic one", () => { + const toastFor = (reason: UploadLogoFailure) => + update(initial, Message.FailedUploadLogo({ reason }), owner).outMessage + expect(toastFor("NotConfigured")).toMatchObject({ toast: { title: "Configuration error" } }) + expect(toastFor("StorageOffline")).toMatchObject({ toast: { title: "Upload failed" } }) + expect(toastFor("NotFound")).toMatchObject({ + toast: errorToast("Organization not found", "This organization may have been deleted."), + }) + expect(toastFor("SignedOut")).toMatchObject({ toast: reasonToast("SignedOut") }) + }) }) describe("public mode", () => { @@ -151,14 +167,19 @@ describe("public mode", () => { given(initial), message(Message.ToggledPublicMode({ isPublic: true })), Command.expectExact(SetPublicMode({ organizationId, isPublic: true })), - Command.resolve(SetPublicMode, Message.FailedSetPublicMode({ toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), - model((current) => expect(current).toMatchObject({ isTogglingPublic: false, isPublic: false })), + model((current) => + expect(current.publicModeSave).toEqual(PublicModeSave.Saving({ isPublic: true })), + ), + Command.resolve(SetPublicMode, Message.FailedSetPublicMode({ reason: "Unexpected" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Unexpected") })), + model((current) => + expect(current).toMatchObject({ publicModeSave: PublicModeSave.Idle(), isPublic: false }), + ), ) }) test("a second toggle while one runs sends nothing", () => { - const toggling: Model = { ...initial, isTogglingPublic: true } + const toggling: Model = { ...initial, publicModeSave: PublicModeSave.Saving({ isPublic: false }) } expect( update(toggling, Message.ToggledPublicMode({ isPublic: true }), owner).commands ?? [], ).toHaveLength(0) @@ -195,17 +216,20 @@ describe("delete workspace", () => { }), ), model((current) => - expect(current).toMatchObject({ confirmationText: "", deleteModal: { isOpen: false } }), + expect(current).toMatchObject({ + confirmationText: "", + deleteConfirm: { state: { _tag: "Closed" }, modal: { isOpen: false } }, + }), ), ) }) test("a second confirm while deleting sends nothing", () => { + const asking = ConfirmAction.ask(initial.deleteConfirm, { organizationId }) const deleting: Model = { ...initial, - deleteModal: { ...initial.deleteModal, isOpen: true }, + deleteConfirm: Option.getOrThrow(ConfirmAction.confirm(asking))[0], confirmationText: "Hazel Labs", - isDeleting: true, } expect(update(deleting, Message.ClickedConfirmDelete(), owner).commands ?? []).toHaveLength(0) }) @@ -218,7 +242,32 @@ describe("delete workspace", () => { message(Message.ChangedConfirmation({ value: "Hazel" })), message(Message.ClickedCancelDelete()), model((current) => - expect(current).toMatchObject({ confirmationText: "", deleteModal: { isOpen: false } }), + expect(current).toMatchObject({ + confirmationText: "", + deleteConfirm: { state: { _tag: "Closed" }, modal: { isOpen: false } }, + }), + ), + message(Message.ClickedDeleteWorkspace()), + model((current) => expect(current.confirmationText).toBe("")), + ) + }) + + test("dismissing the dialog (Escape, backdrop) also clears the confirmation", () => { + story<Model, Message, PageOutMessage>( + storyUpdate(update, owner), + given(initial), + message(Message.ClickedDeleteWorkspace()), + message(Message.ChangedConfirmation({ value: "Hazel" })), + message( + Message.GotDeleteConfirmMessage({ + message: ConfirmAction.toModalMessage(Modal.Message.ClickedClose()), + }), + ), + model((current) => + expect(current).toMatchObject({ + confirmationText: "", + deleteConfirm: { state: { _tag: "Closed" } }, + }), ), ) }) diff --git a/apps/web-foldkit/src/page/settings/general/update.ts b/apps/web-foldkit/src/page/settings/general/update.ts index a238f5dce..5e29f53e0 100644 --- a/apps/web-foldkit/src/page/settings/general/update.ts +++ b/apps/web-foldkit/src/page/settings/general/update.ts @@ -4,77 +4,90 @@ import { Command, Update } from "foldkit" import * as Dom from "foldkit/dom" import { modifyFields } from "foldkit/struct" import { setPublicModeAction, updateOrganizationAction } from "~/db/actions" -import type { ToastRequest } from "../../../overlay/toasts" -import * as Modal from "../../../ui/modal" +import { failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { catchCommon, errorToast, errorToastOf, successToast } from "../../../data/actions" import { HazelRpc } from "../../../rpc" import type { RouteOf } from "../../../route" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, failureToast, settle, successToast } from "../../../data/actions" -import { publicUrlOf, uploadFile } from "../upload" +import * as ConfirmAction from "../../shared/confirm-action" +import { UploadProblem, UploadTarget, uploadFile, uploadProblemMessage } from "../upload" import { Message } from "./message" -import type { Model } from "./model" +import { + CopyTarget, + type DeleteTarget, + type DeleteWorkspaceFailure, + LogoUpload, + type Model, + NameSave, + PublicModeSave, + type RenameFailure, + type SetPublicModeFailure, + type UploadLogoFailure, +} from "./model" type Return = PageReturn<Model, Message> export const LOGO_INPUT_ID = "organization-logo-input" -const organizationNotFound = { - OrganizationNotFoundError: { - title: "Organization not found", - description: "This organization may have been deleted.", - }, -} +/** The Clipboard API refused the write (no permission, or the document isn't focused). */ +class ClipboardWriteError extends Schema.TaggedError<ClipboardWriteError>()("ClipboardWriteError", { + cause: Schema.Defect(), +}) {} // COMMAND export const UpdateOrganizationName = Command.define("UpdateOrganizationName", { args: { organizationId: OrganizationId, name: Schema.String }, messages: [Message.SucceededUpdateName, Message.FailedUpdateName], - execute: ({ organizationId, name }) => - settle( - updateOrganizationAction({ organizationId, name }), - () => Message.SucceededUpdateName(), - (cause) => - Message.FailedUpdateName({ - toast: failureToast(cause, "friendly", { - ...organizationNotFound, - OrganizationSlugAlreadyExistsError: { - title: "Slug already exists", - description: "This organization slug is already taken.", - }, - }), - }), - ), + execute: ({ organizationId, name }) => { + const failed = (reason: RenameFailure) => Effect.succeed(Message.FailedUpdateName({ reason })) + return updateOrganizationAction({ organizationId, name }).pipe( + Effect.as(Message.SucceededUpdateName()), + Effect.catchTags({ + OrganizationNotFoundError: () => failed("NotFound"), + OrganizationSlugAlreadyExistsError: () => failed("SlugTaken"), + }), + catchCommon((reason) => Message.FailedUpdateName({ reason })), + ) + }, }) export const SetPublicMode = Command.define("SetPublicMode", { args: { organizationId: OrganizationId, isPublic: Schema.Boolean }, messages: [Message.SucceededSetPublicMode, Message.FailedSetPublicMode], execute: ({ organizationId, isPublic }) => - settle( - setPublicModeAction({ organizationId, isPublic }), - () => Message.SucceededSetPublicMode({ isPublic }), - (cause) => - Message.FailedSetPublicMode({ toast: failureToast(cause, "friendly", organizationNotFound) }), + setPublicModeAction({ organizationId, isPublic }).pipe( + Effect.as(Message.SucceededSetPublicMode({ isPublic })), + Effect.catchTags({ + OrganizationNotFoundError: () => + Effect.succeed( + Message.FailedSetPublicMode({ reason: "NotFound" satisfies SetPublicModeFailure }), + ), + }), + catchCommon((reason) => Message.FailedSetPublicMode({ reason })), ), }) export const CopyText = Command.define("CopyText", { - args: { text: Schema.String, successTitle: Schema.String, failureTitle: Schema.String }, - messages: [Message.CompletedCopy], - execute: ({ text, successTitle, failureTitle }) => - Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( - Effect.match({ - onSuccess: () => Message.CompletedCopy({ toast: successToast(successTitle) }), - onFailure: () => Message.CompletedCopy({ toast: errorToast(failureTitle) }), + args: { target: CopyTarget, text: Schema.String }, + messages: [Message.SucceededCopy, Message.FailedCopy], + execute: ({ target, text }) => + Effect.tryPromise({ + try: () => navigator.clipboard.writeText(text), + catch: (cause) => new ClipboardWriteError({ cause }), + }).pipe( + Effect.as(Message.SucceededCopy({ target })), + Effect.catchTags({ + ClipboardWriteError: () => + Effect.succeed(Message.FailedCopy({ target, reason: "ClipboardDenied" })), }), ), }) export const OpenLogoPicker = Command.define("OpenLogoPicker", { messages: [Message.CompletedOpenLogoPicker], - // The hidden input's change event reports the file (`SelectedLogo`). + // The hidden input's change event reports the file (`SelectedLogo`). Best effort: a missing input opens nothing. execute: Dom.clickElement(`#${LOGO_INPUT_ID}`).pipe( Effect.ignore, Effect.as(Message.CompletedOpenLogoPicker()), @@ -89,67 +102,41 @@ export const ReadOrigin = Command.define("ReadOrigin", { export const UploadLogo = Command.define("UploadLogo", { args: { organizationId: OrganizationId, file: Schema.instanceOf(File) }, messages: [Message.SucceededUploadLogo, Message.FailedUploadLogo], - execute: ({ organizationId, file }) => - Effect.gen(function* () { + execute: ({ organizationId, file }) => { + const failed = (reason: UploadLogoFailure) => Effect.succeed(Message.FailedUploadLogo({ reason })) + return Effect.sync(() => { // Reset the input so the same file can be picked again. const input = document.getElementById(LOGO_INPUT_ID) if (input instanceof HTMLInputElement) input.value = "" - const key = yield* uploadFile({ type: "organization-avatar", organizationId }, file) - const logoUrl = publicUrlOf(key) - if (logoUrl === null) { - return Message.FailedUploadLogo({ - toast: errorToast( - "Configuration error", - "Image upload is not configured. Please contact support.", - ), - }) - } - return yield* updateOrganizationAction({ organizationId, logoUrl }).pipe( - Effect.as(Message.SucceededUploadLogo()), - Effect.catch(() => - Effect.succeed( - Message.FailedUploadLogo({ - toast: errorToast( - "Upload failed", - "Failed to update organization. Please try again.", - ), - }), - ), - ), - ) }).pipe( - Effect.catchTag("UploadFailedError", (error) => - Effect.succeed( - Message.FailedUploadLogo({ toast: errorToast(error.message, error.description) }), - ), - ), - ), + Effect.andThen(uploadFile(UploadTarget.OrganizationAvatar({ organizationId }), file)), + Effect.flatMap((logoUrl) => updateOrganizationAction({ organizationId, logoUrl })), + Effect.as(Message.SucceededUploadLogo()), + Effect.catchTags({ + UploadFailedError: (error) => failed(error.problem), + OrganizationNotFoundError: () => failed("NotFound"), + OrganizationSlugAlreadyExistsError: () => failed("SlugTaken"), + }), + catchCommon((reason) => Message.FailedUploadLogo({ reason })), + ) + }, }) export const DeleteOrganization = Command.define("DeleteOrganization", { args: { organizationId: OrganizationId }, messages: [Message.SucceededDeleteWorkspace, Message.FailedDeleteWorkspace], - execute: ({ organizationId }) => - settle( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("organization.delete", { id: organizationId }) + execute: ({ organizationId }) => { + const failed = (reason: DeleteWorkspaceFailure) => + Effect.succeed(Message.FailedDeleteWorkspace({ reason })) + return HazelRpc.use((client) => client("organization.delete", { id: organizationId })).pipe( + Effect.as(Message.SucceededDeleteWorkspace()), + Effect.catchTags({ + OrganizationNotFoundError: () => failed("NotFound"), + UnauthorizedError: () => failed("NotPermitted"), }), - () => Message.SucceededDeleteWorkspace(), - (cause) => - Message.FailedDeleteWorkspace({ - toast: failureToast(cause, "friendly", { - OrganizationNotFoundError: { - title: "Workspace not found", - description: "This workspace may have already been deleted.", - }, - UnauthorizedError: { - title: "Unauthorized", - description: "You don't have permission to delete this workspace.", - }, - }), - }), - ), + catchCommon((reason) => Message.FailedDeleteWorkspace({ reason })), + ) + }, }) // INIT @@ -161,12 +148,11 @@ export const init = (route: RouteOf<"SettingsGeneral">, shared: Shared): Return name: shared.organization?.name ?? "", syncedName: shared.organization?.name ?? null, isPublic: false, - isSavingName: false, - isTogglingPublic: false, - isUploading: false, - deleteModal: Modal.init("delete-workspace"), + nameSave: NameSave.Idle(), + logoUpload: LogoUpload.Idle(), + publicModeSave: PublicModeSave.Idle(), + deleteConfirm: ConfirmAction.init<DeleteTarget>("delete-workspace"), confirmationText: "", - isDeleting: false, }, commands: [ReadOrigin()], }) @@ -183,29 +169,49 @@ export const sharedChanged = (model: Model, shared: Shared): Return => { } } +// COPY + +const organizationNotFound: UserErrorMessage = { + title: "Organization not found", + description: "This organization may have been deleted.", +} + +const slugTaken: UserErrorMessage = { + title: "Slug already exists", + description: "This organization slug is already taken.", +} + +const isUploadProblem = Schema.is(UploadProblem) + +const uploadLogoFailureMessage = (reason: UploadLogoFailure): UserErrorMessage => + isUploadProblem(reason) + ? uploadProblemMessage("avatar", reason) + : failureMessage(reason, { NotFound: organizationNotFound, SlugTaken: slugTaken }) + +const COPIED: { readonly [T in CopyTarget]: { readonly success: string; readonly failure: string } } = { + WorkspaceUrl: { success: "Workspace URL copied to clipboard", failure: "Failed to copy URL" }, + InviteLink: { success: "Invite link copied to clipboard", failure: "Failed to copy link" }, +} + // UPDATE export const isAdminOf = (shared: Shared) => shared.member?.role === "owner" || shared.member?.role === "admin" -const toast = (model: Model, request: ToastRequest): Return => ({ - model, - outMessage: PageOutMessage.RequestedToast({ toast: request }), -}) +/** Every way the delete dialog closes clears the typed confirmation. */ +const withDeleteConfirm = (model: Model, deleteConfirm: ConfirmAction.Model<DeleteTarget>): Model => + modifyFields(model, { + deleteConfirm: () => deleteConfirm, + confirmationText: (text) => (deleteConfirm.modal.isOpen ? text : ""), + }) -const deleteModalFold = { - read: (model: Model) => Option.some(model.deleteModal), - write: (model: Model, deleteModal: Modal.Model): Model => - modifyFields(model, { - deleteModal: () => deleteModal, - // Closing the dialog (any path) clears the typed confirmation. - confirmationText: (text) => (deleteModal.isOpen ? text : ""), - }), - toParentMessage: (message: Modal.Message) => Message.GotDeleteModalMessage({ message }), -} -const foldDeleteModal = Update.foldChild({ update: Modal.update, ...deleteModalFold }) -const openDeleteModal = Update.foldChildStep({ update: Modal.open, ...deleteModalFold }) -const closeDeleteModal = Update.foldChildStep({ update: Modal.close, ...deleteModalFold }) +const foldDeleteConfirm = Update.foldChild({ + update: (child: ConfirmAction.Model<DeleteTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(child, message), + read: (model: Model) => Option.some(model.deleteConfirm), + write: withDeleteConfirm, + toParentMessage: (message: ConfirmAction.Message) => Message.GotDeleteConfirmMessage({ message }), +}) export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { @@ -214,92 +220,139 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value }) }), SubmittedName: () => { const organization = shared.organization + const name = model.name.trim() if ( !organization || - !model.name.trim() || + !name || model.name === organization.name || - model.isSavingName + NameSave.guards.Saving(model.nameSave) ) { return { model } } return { - model: modifyFields(model, { isSavingName: () => true }), - commands: [ - UpdateOrganizationName({ organizationId: organization.id, name: model.name.trim() }), - ], + model: modifyFields(model, { nameSave: () => NameSave.Saving() }), + commands: [UpdateOrganizationName({ organizationId: organization.id, name })], } }, ClickedCancelName: () => ({ model: modifyFields(model, { name: () => shared.organization?.name ?? "" }), }), - SucceededUpdateName: () => - toast( - modifyFields(model, { isSavingName: () => false }), - successToast("Organization name updated"), - ), - FailedUpdateName: ({ toast: request }) => - toast(modifyFields(model, { isSavingName: () => false }), request), + SucceededUpdateName: () => ({ + model: modifyFields(model, { nameSave: () => NameSave.Idle() }), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Organization name updated") }), + }), + FailedUpdateName: ({ reason }) => ({ + model: modifyFields(model, { nameSave: () => NameSave.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { NotFound: organizationNotFound, SlugTaken: slugTaken }), + ), + }), + }), ClickedLogo: () => ({ model, commands: [OpenLogoPicker()] }), CompletedOpenLogoPicker: () => ({ model }), SelectedLogo: ({ files }) => - model.isUploading + LogoUpload.guards.Uploading(model.logoUpload) ? { model } : Option.match(Option.all([Array.head(files), Option.fromNullishOr(shared.organization)]), { onNone: () => ({ model }), onSome: ([file, organization]) => ({ - model: modifyFields(model, { isUploading: () => true }), + model: modifyFields(model, { logoUpload: () => LogoUpload.Uploading() }), commands: [UploadLogo({ organizationId: organization.id, file })], }), }), - SucceededUploadLogo: () => - toast( - modifyFields(model, { isUploading: () => false }), - successToast("Organization logo updated"), - ), - FailedUploadLogo: ({ toast: request }) => - toast(modifyFields(model, { isUploading: () => false }), request), + SucceededUploadLogo: () => ({ + model: modifyFields(model, { logoUpload: () => LogoUpload.Idle() }), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Organization logo updated") }), + }), + FailedUploadLogo: ({ reason }) => ({ + model: modifyFields(model, { logoUpload: () => LogoUpload.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(uploadLogoFailureMessage(reason)), + }), + }), ToggledPublicMode: ({ isPublic }) => - shared.organization === null || model.isTogglingPublic + shared.organization === null || PublicModeSave.guards.Saving(model.publicModeSave) ? { model } : { - model: modifyFields(model, { isTogglingPublic: () => true }), + model: modifyFields(model, { + publicModeSave: () => PublicModeSave.Saving({ isPublic }), + }), commands: [SetPublicMode({ organizationId: shared.organization.id, isPublic })], }, - SucceededSetPublicMode: ({ isPublic }) => - toast( - modifyFields(model, { isTogglingPublic: () => false }), - successToast(isPublic ? "Public invites enabled" : "Public invites disabled"), - ), - FailedSetPublicMode: ({ toast: request }) => - toast(modifyFields(model, { isTogglingPublic: () => false }), request), - ClickedCopy: (args) => ({ model, commands: [CopyText(args)] }), - CompletedCopy: ({ toast: request }) => toast(model, request), - ClickedDeleteWorkspace: () => openDeleteModal(model), - GotDeleteModalMessage: ({ message: child }) => foldDeleteModal(model, child), + SucceededSetPublicMode: ({ isPublic }) => ({ + model: modifyFields(model, { publicModeSave: () => PublicModeSave.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: successToast(isPublic ? "Public invites enabled" : "Public invites disabled"), + }), + }), + FailedSetPublicMode: ({ reason }) => ({ + model: modifyFields(model, { publicModeSave: () => PublicModeSave.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(failureMessage(reason, { NotFound: organizationNotFound })), + }), + }), + ClickedCopy: ({ target, text }) => ({ model, commands: [CopyText({ target, text })] }), + SucceededCopy: ({ target }) => ({ + model, + outMessage: PageOutMessage.RequestedToast({ toast: successToast(COPIED[target].success) }), + }), + FailedCopy: ({ target }) => ({ + model, + outMessage: PageOutMessage.RequestedToast({ toast: errorToast(COPIED[target].failure) }), + }), + ClickedDeleteWorkspace: () => + shared.organization === null + ? { model } + : { + model: withDeleteConfirm( + model, + ConfirmAction.ask(model.deleteConfirm, { + organizationId: shared.organization.id, + }), + ), + }, + GotDeleteConfirmMessage: ({ message: child }) => foldDeleteConfirm(model, child), ChangedConfirmation: ({ value }) => ({ model: modifyFields(model, { confirmationText: () => value }), }), - ClickedCancelDelete: () => closeDeleteModal(model), + ClickedCancelDelete: () => + ConfirmAction.isRunning(model.deleteConfirm) + ? { model } + : { model: withDeleteConfirm(model, ConfirmAction.close(model.deleteConfirm)) }, ClickedConfirmDelete: () => - shared.organization === null || - model.confirmationText !== shared.organization.name || - model.isDeleting + shared.organization === null || model.confirmationText !== shared.organization.name ? { model } - : { - model: modifyFields(model, { isDeleting: () => true }), - commands: [DeleteOrganization({ organizationId: shared.organization.id })], - }, - SucceededDeleteWorkspace: () => { - const closed = closeDeleteModal(modifyFields(model, { isDeleting: () => false })) - return { - ...closed, - outMessage: PageOutMessage.RequestedNavigation({ - href: "/", - replace: false, - toast: successToast("Workspace deleted successfully"), - }), - } - }, - FailedDeleteWorkspace: ({ toast: request }) => - toast(modifyFields(model, { isDeleting: () => false }), request), + : Option.match(ConfirmAction.confirm(model.deleteConfirm), { + onNone: () => ({ model }), + onSome: ([deleteConfirm, target]) => ({ + model: withDeleteConfirm(model, deleteConfirm), + commands: [DeleteOrganization(target)], + }), + }), + SucceededDeleteWorkspace: () => ({ + model: withDeleteConfirm(model, ConfirmAction.close(model.deleteConfirm)), + outMessage: PageOutMessage.RequestedNavigation({ + href: "/", + replace: false, + toast: successToast("Workspace deleted successfully"), + }), + }), + FailedDeleteWorkspace: ({ reason }) => ({ + model: withDeleteConfirm(model, ConfirmAction.failed(model.deleteConfirm)), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Workspace not found", + description: "This workspace may have already been deleted.", + }, + NotPermitted: { + title: "Unauthorized", + description: "You don't have permission to delete this workspace.", + }, + }), + ), + }), + }), }) diff --git a/apps/web-foldkit/src/page/settings/general/view.ts b/apps/web-foldkit/src/page/settings/general/view.ts index 2f3e982d9..041f5cf7c 100644 --- a/apps/web-foldkit/src/page/settings/general/view.ts +++ b/apps/web-foldkit/src/page/settings/general/view.ts @@ -12,7 +12,7 @@ import type { Organization } from "../../../session" import type { PageViewInputs, Shared } from "../../contract" import { deleteWorkspaceModal } from "./delete-workspace" import { Message } from "./message" -import type { Model } from "./model" +import { type CopyTarget, LogoUpload, type Model, NameSave, PublicModeSave } from "./model" import { isAdminOf, LOGO_INPUT_ID } from "./update" /** Port of `routes/_app/$orgSlug/settings/index.tsx`. */ @@ -27,21 +27,32 @@ const getInitials = (orgName: string) => { const sectionHeader = ( h: HtmlBuilder<Message>, - options: { readonly icon: Html; readonly title: string; readonly titleClass?: string; readonly text: string }, + options: { + readonly icon: Html + readonly title: string + readonly titleClass?: string + readonly text: string + }, ): Html => h.div( [h.Class("flex flex-col gap-0.5")], [ h.div( [h.Class("flex items-center gap-2")], - [options.icon, h.h2([h.Class(`font-semibold ${options.titleClass ?? "text-fg"} text-lg`)], [options.title])], + [ + options.icon, + h.h2( + [h.Class(`font-semibold ${options.titleClass ?? "text-fg"} text-lg`)], + [options.title], + ), + ], ), h.p([h.Class("text-muted-fg text-sm")], [options.text]), ], ) /** The read-only URL box with its Copy button. */ -const copyRow = (h: HtmlBuilder<Message>, url: string, successTitle: string, failureTitle: string): Html => +const copyRow = (h: HtmlBuilder<Message>, url: string, target: CopyTarget): Html => h.div( [h.Class("flex items-center gap-2")], [ @@ -54,7 +65,7 @@ const copyRow = (h: HtmlBuilder<Message>, url: string, successTitle: string, fai { intent: "secondary", size: "md", - onPress: Message.ClickedCopy({ text: url, successTitle, failureTitle }), + onPress: Message.ClickedCopy({ target, text: url }), }, [IconCopy(h, { attributes: { "data-slot": "icon" } }), "Copy"], ), @@ -64,8 +75,10 @@ const copyRow = (h: HtmlBuilder<Message>, url: string, successTitle: string, fai const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, organization: Organization) => { const isAdmin = isAdminOf(shared) const isPermissionsLoading = shared.member === null - const isLogoDisabled = model.isUploading || isPermissionsLoading || !isAdmin - const isNameDisabled = model.isSavingName || isPermissionsLoading || !isAdmin + const isUploading = LogoUpload.guards.Uploading(model.logoUpload) + const isSavingName = NameSave.guards.Saving(model.nameSave) + const isLogoDisabled = isUploading || isPermissionsLoading || !isAdmin + const isNameDisabled = isSavingName || isPermissionsLoading || !isAdmin const workspaceUrl = `${model.origin}/${model.orgSlug}` return card(h, {}, [ cardHeader(h, [ @@ -92,7 +105,9 @@ const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, orga "group relative cursor-pointer disabled:cursor-not-allowed disabled:opacity-50", ), h.Type("button"), - ...(isLogoDisabled ? [h.Disabled(true)] : [h.OnClick(Message.ClickedLogo())]), + ...(isLogoDisabled + ? [h.Disabled(true)] + : [h.OnClick(Message.ClickedLogo())]), ], [ avatar(h, { @@ -128,9 +143,12 @@ const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, orga [ h.p( [h.Class("font-medium text-fg text-sm")], - [model.isUploading ? "Uploading..." : "Click to upload"], + [isUploading ? "Uploading..." : "Click to upload"], + ), + h.p( + [h.Class("text-muted-fg text-xs")], + ["JPG, PNG or WebP. Max 5MB."], ), - h.p([h.Class("text-muted-fg text-xs")], ["JPG, PNG or WebP. Max 5MB."]), ], ), ], @@ -150,10 +168,14 @@ const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, orga parts.input({ placeholder: "Enter organization name", attributes: [ - ...(isNameDisabled ? [h.Disabled(true), h.DataAttribute("disabled", "true")] : []), + ...(isNameDisabled + ? [h.Disabled(true), h.DataAttribute("disabled", "true")] + : []), h.OnBlur(Message.SubmittedName()), h.OnKeyDownPreventDefault((key) => - key === "Enter" ? Option.some(Message.SubmittedName()) : Option.none(), + key === "Enter" + ? Option.some(Message.SubmittedName()) + : Option.none(), ), ], }), @@ -170,17 +192,17 @@ const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, orga { intent: "primary", size: "sm", - isDisabled: model.isSavingName, + isDisabled: isSavingName, onPress: Message.SubmittedName(), }, - [model.isSavingName ? "Saving..." : "Save Changes"], + [isSavingName ? "Saving..." : "Save Changes"], ), button( h, { intent: "secondary", size: "sm", - isDisabled: model.isSavingName, + isDisabled: isSavingName, onPress: Message.ClickedCancelName(), }, ["Cancel"], @@ -193,8 +215,10 @@ const profileCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared, orga [h.Class("flex flex-col gap-2")], [ Field.label(h, {}, ["Workspace URL"]), - copyRow(h, workspaceUrl, "Workspace URL copied to clipboard", "Failed to copy URL"), - Field.description(h, {}, ["This is your workspace's unique URL. Share it with your team."]), + copyRow(h, workspaceUrl, "WorkspaceUrl"), + Field.description(h, {}, [ + "This is your workspace's unique URL. Share it with your team.", + ]), ], ), ], @@ -223,7 +247,10 @@ const publicInviteCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared) { id: "public-invite-switch", isSelected: model.isPublic, - isDisabled: model.isTogglingPublic || isPermissionsLoading || !isAdminOf(shared), + isDisabled: + PublicModeSave.guards.Saving(model.publicModeSave) || + isPermissionsLoading || + !isAdminOf(shared), onChange: (isPublic) => Message.ToggledPublicMode({ isPublic }), }, "Enable public invite link", @@ -237,7 +264,7 @@ const publicInviteCard = (h: HtmlBuilder<Message>, model: Model, shared: Shared) [h.Class("text-muted-fg text-sm")], ["Anyone with this link can join your workspace as a member."], ), - copyRow(h, publicInviteUrl, "Invite link copied to clipboard", "Failed to copy link"), + copyRow(h, publicInviteUrl, "InviteLink"), ], ), ] diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/command.ts b/apps/web-foldkit/src/page/settings/integrations/detail/command.ts index f833a0328..3484403bb 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/command.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/command.ts @@ -1,10 +1,10 @@ import { IntegrationConnection } from "@hazel/domain/models" import { OrganizationId } from "@hazel/schema" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { load } from "foldkit/navigation" import { HazelApiClient } from "../../../../rpc" -import { failureToast } from "../../../../data/actions" +import { catchCommon } from "../../../../data/actions" import { Message } from "./message" const Provider = IntegrationConnection.IntegrationProvider @@ -22,7 +22,11 @@ export const GetOAuthUrl = Command.define("GetOAuthUrl", { Effect.map((response) => Message.SucceededGetOAuthUrl({ authorizationUrl: response.authorizationUrl }), ), - Effect.catch(() => Effect.succeed(Message.FailedGetOAuthUrl())), + Effect.catchTags({ + UnsupportedProviderError: () => + Effect.succeed(Message.FailedGetOAuthUrl({ reason: "UnsupportedProvider" })), + }), + catchCommon((reason) => Message.FailedGetOAuthUrl({ reason })), ), }) @@ -34,71 +38,46 @@ export const RedirectToProvider = Command.define("RedirectToProvider", { load(authorizationUrl).pipe(Effect.as(Message.CompletedRedirectToProvider())), }) -/** `disconnectMutation` with the `exitToast` of `handleDisconnect` (no success toast). */ +/** `disconnectMutation`, organization level. */ export const Disconnect = Command.define("Disconnect", { args: target, messages: [Message.SucceededDisconnect, Message.FailedDisconnect], execute: (params) => - Effect.exit( - HazelApiClient.use((client) => - client.integrations.disconnect({ params, query: { level: "organization" } }), - ), + HazelApiClient.use((client) => + client.integrations.disconnect({ params, query: { level: "organization" } }), ).pipe( - Effect.map((exit) => - Exit.isSuccess(exit) - ? Message.SucceededDisconnect() - : Message.FailedDisconnect({ - toast: failureToast(exit.cause, "exitToast", { - IntegrationNotConnectedError: () => ({ - title: "Integration not connected", - description: "This integration is already disconnected.", - isRetryable: false, - }), - UnsupportedProviderError: () => ({ - title: "Unsupported provider", - description: "This integration provider is not supported.", - isRetryable: false, - }), - }), - }), - ), + Effect.as(Message.SucceededDisconnect()), + Effect.catchTags({ + IntegrationNotConnectedError: () => + Effect.succeed(Message.FailedDisconnect({ reason: "NotConnected" })), + UnsupportedProviderError: () => + Effect.succeed(Message.FailedDisconnect({ reason: "UnsupportedProvider" })), + }), + catchCommon((reason) => Message.FailedDisconnect({ reason })), ), }) -const messageOf = (error: unknown) => - typeof error === "object" && error !== null && "message" in error ? String(error.message) : undefined - -/** `connectApiKeyMutation` with the toasts of `handleConnectApiKey`. */ +/** `connectApiKeyMutation` */ export const ConnectApiKey = Command.define("ConnectApiKey", { args: { ...target, token: Schema.String, baseUrl: Schema.String }, messages: [Message.SucceededConnectApiKey, Message.FailedConnectApiKey], execute: ({ orgId, provider, token, baseUrl }) => - Effect.exit( - HazelApiClient.use((client) => - client.integrations.connectApiKey({ - params: { orgId, provider }, - payload: { token, baseUrl }, - }), - ), + HazelApiClient.use((client) => + client.integrations.connectApiKey({ + params: { orgId, provider }, + payload: { token, baseUrl }, + }), ).pipe( - Effect.map((exit) => - Exit.isSuccess(exit) - ? Message.SucceededConnectApiKey({ externalAccountName: exit.value.externalAccountName }) - : Message.FailedConnectApiKey({ - toast: failureToast(exit.cause, "exitToast", { - InvalidApiKeyError: (error) => ({ - title: "Invalid credentials", - description: messageOf(error), - isRetryable: true, - }), - UnsupportedProviderError: () => ({ - title: "Unsupported provider", - description: "This integration does not support API key connections.", - isRetryable: false, - }), - }), - }), + Effect.map((response) => + Message.SucceededConnectApiKey({ externalAccountName: response.externalAccountName }), ), + Effect.catchTags({ + InvalidApiKeyError: () => + Effect.succeed(Message.FailedConnectApiKey({ reason: "InvalidApiKey" })), + UnsupportedProviderError: () => + Effect.succeed(Message.FailedConnectApiKey({ reason: "UnsupportedProvider" })), + }), + catchCommon((reason) => Message.FailedConnectApiKey({ reason })), ), }) diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/message.ts b/apps/web-foldkit/src/page/settings/integrations/detail/message.ts index 196589c42..d85f429c4 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/message.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/message.ts @@ -1,23 +1,41 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" import { Connection } from "../shared/connections" +/** What can go wrong fetching the OAuth URL, beyond the shared reasons. */ +export const GetOAuthUrlFailure = Schema.Union([Schema.Literals(["UnsupportedProvider"]), CommonReason]) +export type GetOAuthUrlFailure = typeof GetOAuthUrlFailure.Type + +/** What can go wrong disconnecting, beyond the shared reasons. */ +export const DisconnectFailure = Schema.Union([ + Schema.Literals(["NotConnected", "UnsupportedProvider"]), + CommonReason, +]) +export type DisconnectFailure = typeof DisconnectFailure.Type + +/** What can go wrong connecting with an API key, beyond the shared reasons. */ +export const ConnectApiKeyFailure = Schema.Union([ + Schema.Literals(["InvalidApiKey", "UnsupportedProvider"]), + CommonReason, +]) +export type ConnectApiKeyFailure = typeof ConnectApiKeyFailure.Type + export const Message = defineMessageUnion({ UpdatedConnection: { connection: Schema.NullOr(Connection) }, ClickedBack: {}, ClickedConnect: {}, SucceededGetOAuthUrl: { authorizationUrl: Schema.String }, - FailedGetOAuthUrl: {}, + FailedGetOAuthUrl: { reason: GetOAuthUrlFailure }, CompletedRedirectToProvider: {}, ClickedDisconnect: {}, SucceededDisconnect: {}, - FailedDisconnect: { toast: ToastRequest }, + FailedDisconnect: { reason: DisconnectFailure }, ChangedApiToken: { value: Schema.String }, ChangedApiBaseUrl: { value: Schema.String }, SubmittedApiKeyForm: {}, SucceededConnectApiKey: { externalAccountName: Schema.NullOr(Schema.String) }, - FailedConnectApiKey: { toast: ToastRequest }, + FailedConnectApiKey: { reason: ConnectApiKeyFailure }, ToggledConfigOption: { optionId: Schema.String, isSelected: Schema.Boolean }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/model.ts b/apps/web-foldkit/src/page/settings/integrations/detail/model.ts index ceb10efd6..453e8f0e9 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/model.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/model.ts @@ -1,20 +1,43 @@ import { Schema } from "effect" +import { Field, makeRules, Rule } from "foldkit/fieldValidation" +import { defineTaggedUnion } from "foldkit/schema" import { Connection } from "../shared/connections" export const CallbackStatus = Schema.Literals(["success", "error"]) export type CallbackStatus = typeof CallbackStatus.Type +/** + * What the connection panel is waiting for. `Verifying` follows a successful OAuth callback until the + * connection syncs in active; `Connecting` covers the OAuth URL request and the API key submit. + */ +export const Activity = defineTaggedUnion({ + Idle: {}, + Verifying: {}, + Connecting: {}, + Disconnecting: {}, +}) +export type Activity = typeof Activity.Type + +const isBlank = (value: string) => value.trim() === "" +const isUrl = Rule.url()[0] + +export const apiTokenRules = makeRules({ required: "Bearer token is required", isEmpty: isBlank }) + +/** The submit trims, so the URL check does too. */ +export const apiBaseUrlRules = makeRules({ + required: "Base URL is required", + isEmpty: isBlank, + rules: [[(value: string) => isUrl(value.trim()), "Enter a full URL starting with https://"]], +}) + export const Model = Schema.Struct({ orgSlug: Schema.String, integrationId: Schema.String, /** The org-level connection for this provider (first row), if any. */ connection: Schema.NullOr(Connection), - /** Set by a successful OAuth callback, cleared once the connection syncs in active (`isVerifying`). */ - pendingVerification: Schema.Boolean, - isConnecting: Schema.Boolean, - isDisconnecting: Schema.Boolean, - apiToken: Schema.String, - apiBaseUrl: Schema.String, + activity: Activity, + apiToken: Field(Schema.String), + apiBaseUrl: Field(Schema.String), /** `ConfigOptionRow` toggles, local only as in legacy. */ enabledOptionIds: Schema.Array(Schema.String), }) diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts index 0b5f8400b..3c171048b 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/scene.test.ts @@ -19,10 +19,10 @@ import { import { describe, expect, test } from "vitest" import type { Shared } from "../../../contract" import { PageOutMessage } from "../../../out-message" -import { errorToast, successToast } from "../../../../data/actions" +import { errorToast, reasonToast, successToast } from "../../../../data/actions" import { ConnectApiKey, Disconnect, GetOAuthUrl, RedirectToProvider } from "./command" import { Message } from "./message" -import type { Model } from "./model" +import { Activity, type Model } from "./model" import { init, update } from "./update" import { view } from "./view" import { sharedDefaults } from "../../../test-shared" @@ -108,7 +108,6 @@ describe("integration detail page", () => { scene( config, given(pageFor("craft").model), - expectView(role("button", { name: "Connect" })).toBeDisabled(), typeInto(placeholder("Enter your API token"), " secret "), typeInto(placeholder("https://connect.craft.do/links/{linkId}/api/v1"), "https://craft.test/api"), submit("form"), @@ -138,7 +137,7 @@ describe("integration detail page", () => { toast: successToast("Connected to Linear", "Your account has been successfully connected."), }), ) - expect(page.model.pendingVerification).toBe(true) + expect(page.model.activity).toEqual(Activity.Verifying()) }) test("no callback params, or an unknown status, show nothing", () => { @@ -187,15 +186,8 @@ describe("integration detail flows", () => { config, given(pageFor("linear").model), click(role("button", { name: /Connect with Linear$/ })), - Command.resolve(GetOAuthUrl, Message.FailedGetOAuthUrl()), - expectOutMessage( - PageOutMessage.RequestedToast({ - toast: errorToast( - "Failed to connect to Linear", - "Could not initiate the connection. Please try again.", - ), - }), - ), + Command.resolve(GetOAuthUrl, Message.FailedGetOAuthUrl({ reason: "Offline" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Offline") })), expectView(role("button", { name: /Connect with Linear$/ })).toBeEnabled(), ) }) @@ -206,27 +198,46 @@ describe("integration detail flows", () => { config, given({ ...pageFor("linear").model, connection: linear }), click(role("button", { name: "Disconnect" })), - Command.resolve(Disconnect, Message.FailedDisconnect({ toast: failure })), + Command.resolve(Disconnect, Message.FailedDisconnect({ reason: "NotConnected" })), expectOutMessage(PageOutMessage.RequestedToast({ toast: failure })), expectView(text("Hazel Labs")).toExist(), expectView(role("button", { name: "Disconnect" })).toBeEnabled(), ) }) - test("Connect stays disabled until both API key fields have text; a failure re-enables it", () => { - const failure = errorToast("Invalid credentials", "Token rejected") + test("submitting an empty API key form shows each field's error and sends nothing", () => { + scene( + config, + given(pageFor("craft").model), + submit("form"), + Command.expectNone(), + expectView(text("Bearer token is required")).toExist(), + expectView(text("Base URL is required")).toExist(), + expectView(role("button", { name: /Connect$/ })).toBeDisabled(), + ) + }) + + test("invalid fields show their error and block Connect until fixed; a failure re-enables it", () => { + const failure = errorToast( + "Invalid credentials", + "Check the bearer token and base URL, then try again.", + ) scene( config, given(pageFor("craft").model), typeInto(label("Bearer Token"), " "), - typeInto(label("Base URL"), "https://craft.test/api"), + expectView(text("Bearer token is required")).toExist(), + typeInto(label("Base URL"), "craft.test/api"), + expectView(text("Enter a full URL starting with https://")).toExist(), expectView(role("button", { name: /Connect$/ })).toBeDisabled(), typeInto(label("Bearer Token"), "secret"), + typeInto(label("Base URL"), "https://craft.test/api"), + expectView(text("Enter a full URL starting with https://")).toBeAbsent(), expectView(role("button", { name: /Connect$/ })).toBeEnabled(), click(role("button", { name: /Connect$/ })), Command.expectHas(ConnectApiKey), expectView(role("button", { name: /Connecting\.\.\.$/ })).toBeDisabled(), - Command.resolve(ConnectApiKey, Message.FailedConnectApiKey({ toast: failure })), + Command.resolve(ConnectApiKey, Message.FailedConnectApiKey({ reason: "InvalidApiKey" })), expectOutMessage(PageOutMessage.RequestedToast({ toast: failure })), expectView(role("button", { name: /Connect$/ })).toBeEnabled(), ) diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/detail/story.test.ts index 9527f3813..04e7ad939 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/story.test.ts @@ -1,10 +1,12 @@ // @vitest-environment jsdom import { Option } from "effect" +import { NotValidated } from "foldkit/fieldValidation" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { makeShared, organizationId, storyUpdate } from "../../../../test/pages-fixtures" import { ConnectApiKey, Disconnect, GetOAuthUrl } from "./command" import { Message } from "./message" +import { Activity } from "./model" import { init, update } from "./update" /** The integration detail update: organization gating, config toggles and request guards. */ @@ -45,13 +47,50 @@ describe("integration detail update", () => { ) }) - test("an API key form with a blank field sends nothing", () => { + test("submitting an untouched API key form sends nothing and shows why", () => { story( run, - given({ ...pageFor("craft"), apiToken: "secret", apiBaseUrl: " " }), + given(pageFor("craft")), message(Message.SubmittedApiKeyForm()), Command.expectNone(), - model((current) => expect(current.isConnecting).toBe(false)), + model((current) => { + expect(current.activity).toEqual(Activity.Idle()) + expect(current.apiToken).toEqual({ + _tag: "Invalid", + value: "", + errors: ["Bearer token is required"], + }) + expect(current.apiBaseUrl).toEqual({ + _tag: "Invalid", + value: "", + errors: ["Base URL is required"], + }) + }), + ) + }) + + test("a blank token or a base URL that is not a URL sends nothing", () => { + story( + run, + given({ + ...pageFor("craft"), + apiToken: NotValidated({ value: "secret" }), + apiBaseUrl: NotValidated({ value: " " }), + }), + message(Message.SubmittedApiKeyForm()), + Command.expectNone(), + message(Message.ChangedApiBaseUrl({ value: "craft.test/api" })), + message(Message.SubmittedApiKeyForm()), + Command.expectNone(), + model((current) => + expect(current.apiBaseUrl).toEqual({ + _tag: "Invalid", + value: "craft.test/api", + errors: ["Enter a full URL starting with https://"], + }), + ), + message(Message.ChangedApiToken({ value: " " })), + model((current) => expect(current.apiToken._tag).toBe("Invalid")), ) }) @@ -60,10 +99,11 @@ describe("integration detail update", () => { run, given(linear), message(Message.ClickedConnect()), - Command.resolve(GetOAuthUrl, Message.FailedGetOAuthUrl()), + Command.resolve(GetOAuthUrl, Message.FailedGetOAuthUrl({ reason: "Offline" })), ) expect( - update({ ...linear, isConnecting: true }, Message.ClickedConnect(), shared).commands ?? [], + update({ ...linear, activity: Activity.Connecting() }, Message.ClickedConnect(), shared) + .commands ?? [], ).toHaveLength(0) }) @@ -75,12 +115,17 @@ describe("integration detail update", () => { Command.resolve(Disconnect, Message.SucceededDisconnect()), ) expect( - update({ ...linear, isDisconnecting: true }, Message.ClickedDisconnect(), shared).commands ?? [], + update({ ...linear, activity: Activity.Disconnecting() }, Message.ClickedDisconnect(), shared) + .commands ?? [], ).toHaveLength(0) }) test("a second API key submit while connecting sends nothing", () => { - const filled = { ...pageFor("craft"), apiToken: "secret", apiBaseUrl: "https://craft.test/api" } + const filled = { + ...pageFor("craft"), + apiToken: NotValidated({ value: "secret" }), + apiBaseUrl: NotValidated({ value: " https://craft.test/api " }), + } story( run, given(filled), @@ -96,7 +141,8 @@ describe("integration detail update", () => { Command.resolve(ConnectApiKey, Message.SucceededConnectApiKey({ externalAccountName: null })), ) expect( - update({ ...filled, isConnecting: true }, Message.SubmittedApiKeyForm(), shared).commands ?? [], + update({ ...filled, activity: Activity.Connecting() }, Message.SubmittedApiKeyForm(), shared) + .commands ?? [], ).toHaveLength(0) }) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/update.ts b/apps/web-foldkit/src/page/settings/integrations/detail/update.ts index 2d37c858f..43acc28b0 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/update.ts @@ -1,12 +1,14 @@ import { Option, Schema } from "effect" +import { allValid, NotValidated, validate } from "foldkit/fieldValidation" +import { failureMessage } from "~/lib/error-messages" import { getIntegrationById } from "~/lib/integrations/__data" import { AppRoute, hrefOf, integrationHref, type RouteOf } from "../../../../route" import type { PageReturn, Shared } from "../../../contract" import { PageOutMessage } from "../../../out-message" -import { errorToast, successToast } from "../../../../data/actions" +import { errorToast, errorToastOf, successToast } from "../../../../data/actions" import { ConnectApiKey, Disconnect, GetOAuthUrl, isProvider, RedirectToProvider } from "./command" import { Message } from "./message" -import { CallbackStatus, type Model } from "./model" +import { Activity, apiBaseUrlRules, apiTokenRules, CallbackStatus, type Model } from "./model" const callbackStatus = Schema.decodeUnknownOption(CallbackStatus) @@ -34,11 +36,9 @@ export const init = (route: Route): Return => orgSlug: route.orgSlug, integrationId: route.integrationId, connection: null, - pendingVerification: false, - isConnecting: false, - isDisconnecting: false, - apiToken: "", - apiBaseUrl: "", + activity: Activity.Idle(), + apiToken: NotValidated({ value: "" }), + apiBaseUrl: NotValidated({ value: "" }), enabledOptionIds: [], }, route, @@ -49,10 +49,10 @@ export const routeChanged = (model: Model, route: Route): Return => oauthCallbac const nameOf = (model: Model) => getIntegrationById(model.integrationId)?.name -const toast = (model: Model, request: Parameters<typeof PageOutMessage.RequestedToast>[0]["toast"]) => ({ - model, - outMessage: PageOutMessage.RequestedToast({ toast: request }), -}) +const UNSUPPORTED_PROVIDER = { + title: "Unsupported provider", + description: "This integration provider is not supported.", +} /** The OAuth callback redirect's `connection_status` and `error_code`: toast and clean the URL. */ const oauthCallback = (model: Model, route: Route): Return => { @@ -60,7 +60,7 @@ const oauthCallback = (model: Model, route: Route): Return => { const name = nameOf(model) if (status === null || name === undefined) return { model } return { - model: { ...model, pendingVerification: model.pendingVerification || status === "success" }, + model: status === "success" ? { ...model, activity: Activity.Verifying() } : model, outMessage: PageOutMessage.RequestedNavigation({ href: integrationHref(model.orgSlug, model.integrationId), replace: true, @@ -81,6 +81,9 @@ const targetOf = (model: Model, shared: Shared) => ? { orgId: shared.organization.id, provider: model.integrationId } : null +const validateApiToken = validate(apiTokenRules) +const validateApiBaseUrl = validate(apiBaseUrlRules) + export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { UpdatedConnection: ({ connection }) => ({ @@ -88,7 +91,10 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ...model, connection, // Verified: a later disconnect shows Connect again, not "Verifying connection...". - pendingVerification: model.pendingVerification && connection?.isActive !== true, + activity: + model.activity._tag === "Verifying" && connection?.isActive === true + ? Activity.Idle() + : model.activity, }, }), ClickedBack: () => ({ @@ -100,53 +106,92 @@ export const update = (model: Model, message: Message, shared: Shared): Return = }), ClickedConnect: () => { const target = targetOf(model, shared) - return target === null || model.isConnecting + return target === null || model.activity._tag !== "Idle" ? { model } - : { model: { ...model, isConnecting: true }, commands: [GetOAuthUrl(target)] } + : { model: { ...model, activity: Activity.Connecting() }, commands: [GetOAuthUrl(target)] } }, SucceededGetOAuthUrl: ({ authorizationUrl }) => ({ model, commands: [RedirectToProvider({ authorizationUrl })], }), - FailedGetOAuthUrl: () => - toast( - { ...model, isConnecting: false }, - errorToast( - `Failed to connect to ${nameOf(model) ?? "integration"}`, - "Could not initiate the connection. Please try again.", - ), - ), + FailedGetOAuthUrl: ({ reason }) => ({ + model: { ...model, activity: Activity.Idle() }, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(failureMessage(reason, { UnsupportedProvider: UNSUPPORTED_PROVIDER })), + }), + }), CompletedRedirectToProvider: () => ({ model }), ClickedDisconnect: () => { const target = targetOf(model, shared) - return target === null || model.isDisconnecting + return target === null || model.activity._tag !== "Idle" ? { model } - : { model: { ...model, isDisconnecting: true }, commands: [Disconnect(target)] } + : { model: { ...model, activity: Activity.Disconnecting() }, commands: [Disconnect(target)] } }, - SucceededDisconnect: () => ({ model: { ...model, isDisconnecting: false } }), - FailedDisconnect: ({ toast: request }) => toast({ ...model, isDisconnecting: false }, request), - ChangedApiToken: ({ value }) => ({ model: { ...model, apiToken: value } }), - ChangedApiBaseUrl: ({ value }) => ({ model: { ...model, apiBaseUrl: value } }), + SucceededDisconnect: () => ({ model: { ...model, activity: Activity.Idle() } }), + FailedDisconnect: ({ reason }) => ({ + model: { ...model, activity: Activity.Idle() }, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotConnected: { + title: "Integration not connected", + description: "This integration is already disconnected.", + }, + UnsupportedProvider: UNSUPPORTED_PROVIDER, + }), + ), + }), + }), + ChangedApiToken: ({ value }) => ({ model: { ...model, apiToken: validateApiToken(value) } }), + ChangedApiBaseUrl: ({ value }) => ({ model: { ...model, apiBaseUrl: validateApiBaseUrl(value) } }), SubmittedApiKeyForm: () => { const target = targetOf(model, shared) - const token = model.apiToken.trim() - const baseUrl = model.apiBaseUrl.trim() - return target === null || !token || !baseUrl || model.isConnecting - ? { model } - : { - model: { ...model, isConnecting: true }, - commands: [ConnectApiKey({ ...target, token, baseUrl })], + if (target === null || model.activity._tag !== "Idle") return { model } + const apiToken = validateApiToken(model.apiToken.value) + const apiBaseUrl = validateApiBaseUrl(model.apiBaseUrl.value) + const validated = { ...model, apiToken, apiBaseUrl } + return allValid([ + [apiToken, apiTokenRules], + [apiBaseUrl, apiBaseUrlRules], + ]) + ? { + model: { ...validated, activity: Activity.Connecting() }, + commands: [ + ConnectApiKey({ + ...target, + token: apiToken.value.trim(), + baseUrl: apiBaseUrl.value.trim(), + }), + ], } + : { model: validated } }, - SucceededConnectApiKey: ({ externalAccountName }) => - toast( - { ...model, isConnecting: false }, - successToast( + SucceededConnectApiKey: ({ externalAccountName }) => ({ + model: { ...model, activity: Activity.Idle() }, + outMessage: PageOutMessage.RequestedToast({ + toast: successToast( `Connected to ${nameOf(model) ?? "integration"}`, `Connected as ${externalAccountName ?? "your space"}.`, ), - ), - FailedConnectApiKey: ({ toast: request }) => toast({ ...model, isConnecting: false }, request), + }), + }), + FailedConnectApiKey: ({ reason }) => ({ + model: { ...model, activity: Activity.Idle() }, + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + InvalidApiKey: { + title: "Invalid credentials", + description: "Check the bearer token and base URL, then try again.", + }, + UnsupportedProvider: { + title: "Unsupported provider", + description: "This integration does not support API key connections.", + }, + }), + ), + }), + }), ToggledConfigOption: ({ optionId, isSelected }) => ({ model: { ...model, diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/view-connection.ts b/apps/web-foldkit/src/page/settings/integrations/detail/view-connection.ts index a76447f62..05ccdc72a 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/view-connection.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/view-connection.ts @@ -1,3 +1,4 @@ +import { anyInvalid, type Field as ValidatedField } from "foldkit/fieldValidation" import type { Html, HtmlBuilder } from "foldkit/html" import { type ConfigOption, getBrandfetchIcon, type Integration } from "~/lib/integrations/__data" import { button } from "../../../../ui/button" @@ -87,6 +88,8 @@ export const connectedState = ( options: { readonly canConfigure: boolean }, ): Html => { const connection = model.connection + const isConnecting = model.activity._tag === "Connecting" + const isDisconnecting = model.activity._tag === "Disconnecting" const showConfigureButton = options.canConfigure && integration.id === "github" && connection?.hasInstallationId return h.div( @@ -126,9 +129,9 @@ export const connectedState = ( intent: "secondary", size: "sm", onPress: Message.ClickedConnect(), - isDisabled: model.isConnecting, + isDisabled: isConnecting, }, - model.isConnecting + isConnecting ? [spinner(h, "size-4 animate-spin"), "Redirecting..."] : [ strokeIcon(h, { className: "size-4", strokeWidth: "2", d: SLIDERS_ICON }), @@ -141,9 +144,9 @@ export const connectedState = ( intent: "danger", size: "sm", onPress: Message.ClickedDisconnect(), - isDisabled: model.isDisconnecting, + isDisabled: isDisconnecting, }, - [model.isDisconnecting ? "Disconnecting..." : "Disconnect"], + [isDisconnecting ? "Disconnecting..." : "Disconnect"], ), ], ) @@ -156,12 +159,14 @@ const apiField = ( readonly label: string readonly type: string readonly placeholder: string - readonly value: string + readonly field: ValidatedField<string> readonly onInput: (value: string) => Message readonly hint: Array<string> }, -): Html => - h.div( +): Html => { + const error = options.field._tag === "Invalid" ? options.field.errors[0] : null + const errorId = `${options.id}-error` + return h.div( [h.Class("flex flex-col gap-1.5")], [ h.label([h.For(options.id), h.Class("font-medium text-fg text-sm")], [options.label]), @@ -169,21 +174,27 @@ const apiField = ( input(h, { placeholder: options.placeholder, className: "text-sm", + isInvalid: error !== null, attributes: [ h.Id(options.id), h.Type(options.type), h.Attribute("autocomplete", "off"), - h.Value(options.value), + h.Value(options.field.value), h.OnInput(options.onInput), + ...(error === null ? [] : [h.AriaDescribedBy(errorId)]), ], }), ]), - h.p([h.Class("text-muted-fg text-xs")], options.hint), + error === null + ? h.p([h.Class("text-muted-fg text-xs")], options.hint) + : Field.fieldError(h, { id: errorId }, [error]), ], ) +} -export const apiKeyConnectionForm = (h: H, integration: Integration, model: Model): Html => - h.form( +export const apiKeyConnectionForm = (h: H, integration: Integration, model: Model): Html => { + const isConnecting = model.activity._tag === "Connecting" + return h.form( [h.Class("flex flex-col gap-5 py-2"), h.OnSubmit(Message.SubmittedApiKeyForm())], [ apiField(h, { @@ -191,7 +202,7 @@ export const apiKeyConnectionForm = (h: H, integration: Integration, model: Mode label: "Bearer Token", type: "password", placeholder: "Enter your API token", - value: model.apiToken, + field: model.apiToken, onInput: (value) => Message.ChangedApiToken({ value }), hint: ["You can find this in your ", integration.name, " API settings."], }), @@ -200,7 +211,7 @@ export const apiKeyConnectionForm = (h: H, integration: Integration, model: Mode label: "Base URL", type: "url", placeholder: "https://connect.craft.do/links/{linkId}/api/v1", - value: model.apiBaseUrl, + field: model.apiBaseUrl, onInput: (value) => Message.ChangedApiBaseUrl({ value }), hint: ["The API endpoint URL for your space."], }), @@ -210,15 +221,16 @@ export const apiKeyConnectionForm = (h: H, integration: Integration, model: Mode intent: "primary", size: "md", className: "self-start", - isDisabled: model.isConnecting || !model.apiToken.trim() || !model.apiBaseUrl.trim(), + isDisabled: isConnecting || anyInvalid([model.apiToken, model.apiBaseUrl]), attributes: [h.Type("submit"), h.Style({ backgroundColor: integration.brandColor })], }, - model.isConnecting + isConnecting ? [spinner(h, "size-4 animate-spin"), "Connecting..."] : [brandIcon(h, integration), "Connect"], ), ], ) +} export const configOptionRow = (h: H, option: ConfigOption, isEnabled: boolean): Html => h.keyed("div")( diff --git a/apps/web-foldkit/src/page/settings/integrations/detail/view.ts b/apps/web-foldkit/src/page/settings/integrations/detail/view.ts index 0bdd4e768..4e24de7dd 100644 --- a/apps/web-foldkit/src/page/settings/integrations/detail/view.ts +++ b/apps/web-foldkit/src/page/settings/integrations/detail/view.ts @@ -113,14 +113,14 @@ const mainColumn = (h: H, integration: Integration, model: Model, hasOrganizatio : apiKeyConnectionForm(h, integration, model), ]), ] - const isVerifying = model.pendingVerification && !isConnected + const isVerifying = model.activity._tag === "Verifying" && !isConnected return [ panel(h, "Connection", [ isVerifying ? verifyingState(h, integration) : isConnected ? connectedState(h, integration, model, { canConfigure: true }) - : disconnectedState(h, integration, model.isConnecting), + : disconnectedState(h, integration, model.activity._tag === "Connecting"), ]), ...(isConnected ? [ diff --git a/apps/web-foldkit/src/page/settings/integrations/index/message.ts b/apps/web-foldkit/src/page/settings/integrations/index/message.ts index 52b0eb6f7..c79225b69 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/message.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/message.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" import { Connection } from "../shared/connections" export const Message = defineMessageUnion({ @@ -7,7 +8,7 @@ export const Message = defineMessageUnion({ ClickedIntegration: { integrationId: Schema.String }, UpdatedConnections: { connections: Schema.Array(Connection) }, SucceededListWebhooks: { names: Schema.Array(Schema.String) }, - FailedListWebhooks: {}, + FailedListWebhooks: { reason: CommonReason }, ClickedRequestIntegration: {}, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts index 39068edd1..146bb2c6b 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/scene.test.ts @@ -34,7 +34,9 @@ describe("integrations catalog", () => { Scene.scene( config, Scene.given(initial), - Scene.Subscription.emit(Message.UpdatedConnections({ connections: [{ ...linear, isActive: false }] })), + Scene.Subscription.emit( + Message.UpdatedConnections({ connections: [{ ...linear, isActive: false }] }), + ), Scene.expect(card("Linear")).toContainText("Not connected"), ) }) @@ -60,7 +62,10 @@ describe("integrations catalog", () => { Scene.expect(Scene.text("Figma")).toExist(), Scene.click(card("Linear")), Scene.expectOutMessage( - PageOutMessage.RequestedNavigation({ href: "/hazel/settings/integrations/linear", replace: false }), + PageOutMessage.RequestedNavigation({ + href: "/hazel/settings/integrations/linear", + replace: false, + }), ), ) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/index/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/index/story.test.ts index c002416f4..9fce48586 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/story.test.ts @@ -34,7 +34,7 @@ describe("webhook providers", () => { story( update, given(init(route, noOrganization).model), - message(Message.FailedListWebhooks()), + message(Message.FailedListWebhooks({ reason: "Unavailable" })), model((current) => expect(current.webhookProviders).toEqual([])), ) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/index/update.ts b/apps/web-foldkit/src/page/settings/integrations/index/update.ts index aacb6d289..8e16641fb 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/update.ts @@ -5,17 +5,19 @@ import { HazelRpc } from "../../../../rpc" import { integrationHref, type RouteOf } from "../../../../route" import type { PageReturn, Shared } from "../../../contract" import { PageOutMessage } from "../../../out-message" +import { catchCommon } from "../../../../data/actions" import { Message } from "./message" import type { Model } from "./model" /** `listOrganizationWebhooksMutation({ payload: {} })`, once the user's organization is known. */ export const ListOrganizationWebhooks = Command.define("ListOrganizationWebhooks", { messages: [Message.SucceededListWebhooks, Message.FailedListWebhooks], - execute: Effect.gen(function* () { - const client = yield* HazelRpc - const result = yield* client("channelWebhook.listByOrganization", {}) - return Message.SucceededListWebhooks({ names: result.data.map((webhook) => webhook.name) }) - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedListWebhooks()))), + execute: HazelRpc.use((rpc) => rpc("channelWebhook.listByOrganization", {})).pipe( + Effect.map((result) => + Message.SucceededListWebhooks({ names: result.data.map((webhook) => webhook.name) }), + ), + catchCommon((reason) => Message.FailedListWebhooks({ reason })), + ), }) const requestWebhooksOnce = (model: Model, shared: Shared): PageReturn<Model, Message> => @@ -60,6 +62,7 @@ export const update = (model: Model, message: Message): PageReturn<Model, Messag webhookProviders: () => Array.from(new Set(names.map((name) => name.toLowerCase()))), }), }), + // Best effort: without the webhook names the OpenStatus and Railway cards just read "not connected". FailedListWebhooks: () => ({ model }), ClickedRequestIntegration: () => ({ model, diff --git a/apps/web-foldkit/src/page/settings/integrations/index/view.ts b/apps/web-foldkit/src/page/settings/integrations/index/view.ts index dbc9a8d37..df2f8f99f 100644 --- a/apps/web-foldkit/src/page/settings/integrations/index/view.ts +++ b/apps/web-foldkit/src/page/settings/integrations/index/view.ts @@ -30,9 +30,7 @@ const connectionStatus = (h: HtmlBuilder<Message>, connected: boolean, comingSoo : h.div( [h.Class("flex items-center gap-1.5")], [ - h.div( - [h.Class(`size-1.5 rounded-full ${connected ? "bg-success" : "bg-secondary"}`)], - ), + h.div([h.Class(`size-1.5 rounded-full ${connected ? "bg-success" : "bg-secondary"}`)]), h.span( [h.Class(`text-xs ${connected ? "text-success" : "text-muted-fg"}`)], [connected ? "Connected" : "Not connected"], @@ -173,11 +171,13 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, ), button( h, - { intent: "secondary", size: "md", className: "shrink-0", onPress: Message.ClickedRequestIntegration() }, - [ - IconPlus(h, { attributes: { "data-slot": "icon" } }), - "Request integration", - ], + { + intent: "secondary", + size: "md", + className: "shrink-0", + onPress: Message.ClickedRequestIntegration(), + }, + [IconPlus(h, { attributes: { "data-slot": "icon" } }), "Request integration"], ), ]), ]), diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/message.ts b/apps/web-foldkit/src/page/settings/integrations/installed/message.ts index ad341ee08..0254c24b9 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/message.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/message.ts @@ -1,14 +1,18 @@ import { BotId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" import { Bot } from "../shared/bots" +/** What can go wrong uninstalling a bot, beyond the shared reasons. */ +export const UninstallFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type UninstallFailure = typeof UninstallFailure.Type + export const Message = defineMessageUnion({ UpdatedBots: { bots: Schema.Array(Bot) }, ClickedUninstall: { botId: BotId }, SucceededUninstallBot: { botId: BotId }, - FailedUninstallBot: { botId: BotId, toast: ToastRequest }, + FailedUninstallBot: { botId: BotId, reason: UninstallFailure }, ClickedBrowseMarketplace: {}, ClickedInstallById: {}, }) diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts index 17c6c15d0..f13d4b2e4 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/scene.test.ts @@ -1,10 +1,10 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { failureToastFixture, makeShared, pageScene } from "../../../../test/pages-fixtures" +import { makeShared, pageScene } from "../../../../test/pages-fixtures" import { bot, botId } from "../../../../test/pages-integrations-fixtures" import { PageOutMessage } from "../../../out-message" -import { successToast } from "../../../../data/actions" +import { reasonToast, successToast } from "../../../../data/actions" import { Message } from "./message" import { init, UninstallBot, update } from "./update" import { view } from "./view" @@ -66,9 +66,9 @@ describe("installed apps", () => { Scene.click(Scene.role("button", { name: "Uninstall" })), Scene.Command.resolve( UninstallBot, - Message.FailedUninstallBot({ botId: bot.id, toast: failureToastFixture }), + Message.FailedUninstallBot({ botId: bot.id, reason: "Unavailable" }), ), - Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("Unavailable") })), Scene.expect(Scene.text("Deploy Bot")).toExist(), ) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts index 025458b34..bb985d236 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/story.test.ts @@ -21,7 +21,7 @@ describe("installed apps", () => { given(initial), message(Message.ClickedUninstall({ botId })), Command.expectExact(UninstallBot({ botId })), - Command.resolve(UninstallBot, Message.FailedUninstallBot({ botId, toast })), + Command.resolve(UninstallBot, Message.FailedUninstallBot({ botId, reason: "NotFound" })), expectOutMessage(PageOutMessage.RequestedToast({ toast })), ) }) @@ -65,7 +65,7 @@ describe("installed apps outcomes", () => { test("a second Uninstall while uninstalling sends nothing; a settled one can run again", () => { const uninstalling = update(initial, Message.ClickedUninstall({ botId })).model expect(update(uninstalling, Message.ClickedUninstall({ botId })).commands ?? []).toHaveLength(0) - const failed = update(uninstalling, Message.FailedUninstallBot({ botId, toast })).model + const failed = update(uninstalling, Message.FailedUninstallBot({ botId, reason: "NotFound" })).model expect(failed.uninstallingBotIds).toEqual([]) expect(update(failed, Message.ClickedUninstall({ botId })).commands ?? []).toHaveLength(1) }) diff --git a/apps/web-foldkit/src/page/settings/integrations/installed/update.ts b/apps/web-foldkit/src/page/settings/integrations/installed/update.ts index acef97af2..3f88c6185 100644 --- a/apps/web-foldkit/src/page/settings/integrations/installed/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/installed/update.ts @@ -1,37 +1,29 @@ import { BotId } from "@hazel/schema" -import { Effect, Exit } from "effect" +import { Effect } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import { HazelRpc } from "../../../../rpc" import { AppRoute, hrefOf, type RouteOf } from "../../../../route" import type { PageReturn } from "../../../contract" import { PageOutMessage } from "../../../out-message" -import { failureToast, rateLimitMessage, successToast } from "../../../../data/actions" +import { catchCommon, errorToastOf, successToast } from "../../../../data/actions" +import { failureMessage } from "~/lib/error-messages" import { Message } from "./message" import type { Model } from "./model" -/** `uninstallBotMutation` with the toasts of `handleUninstall`. */ +/** `uninstallBotMutation` */ export const UninstallBot = Command.define("UninstallBot", { args: { botId: BotId }, messages: [Message.SucceededUninstallBot, Message.FailedUninstallBot], execute: ({ botId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("bot.uninstall", { botId })) - return Exit.isSuccess(exit) - ? Message.SucceededUninstallBot({ botId }) - : Message.FailedUninstallBot({ - botId, - toast: failureToast(exit.cause, "exitToast", { - BotNotFoundError: () => ({ - title: "Application not found", - description: "This application may have already been uninstalled.", - isRetryable: false, - }), - RateLimitExceededError: rateLimitMessage, - }), - }) - }), + HazelRpc.use((rpc) => rpc("bot.uninstall", { botId })).pipe( + Effect.as(Message.SucceededUninstallBot({ botId })), + Effect.catchTags({ + BotNotFoundError: () => + Effect.succeed(Message.FailedUninstallBot({ botId, reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedUninstallBot({ botId, reason })), + ), }) type Return = PageReturn<Model, Message> @@ -59,9 +51,18 @@ export const update = (model: Model, message: Message): Return => toast: successToast("Application uninstalled successfully"), }), }), - FailedUninstallBot: ({ botId, toast }) => ({ + FailedUninstallBot: ({ botId, reason }) => ({ model: settled(model, botId), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Application not found", + description: "This application may have already been uninstalled.", + }, + }), + ), + }), }), ClickedBrowseMarketplace: () => ({ model, diff --git a/apps/web-foldkit/src/page/settings/integrations/marketplace/message.ts b/apps/web-foldkit/src/page/settings/integrations/marketplace/message.ts index f535546a3..4dc82e106 100644 --- a/apps/web-foldkit/src/page/settings/integrations/marketplace/message.ts +++ b/apps/web-foldkit/src/page/settings/integrations/marketplace/message.ts @@ -1,17 +1,21 @@ import { BotId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../../ui/aria/interaction" -import { ToastRequest } from "../../../../overlay/toasts" import { PublicBot } from "../shared/bots" +/** What can go wrong installing a bot, beyond the shared reasons. */ +export const InstallFailure = Schema.Union([Schema.Literals(["NotFound", "AlreadyInstalled"]), CommonReason]) +export type InstallFailure = typeof InstallFailure.Type + export const Message = defineMessageUnion({ UpdatedPublicBots: { bots: Schema.Array(PublicBot) }, UpdatedInstalledBotIds: { botIds: Schema.Array(BotId) }, ChangedSearch: { search: Schema.String }, ClickedInstall: { botId: BotId }, SucceededInstallBot: { botId: BotId }, - FailedInstallBot: { botId: BotId, toast: ToastRequest }, + FailedInstallBot: { botId: BotId, reason: InstallFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts index 283ec16fa..48caa1646 100644 --- a/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts @@ -62,7 +62,7 @@ describe("marketplace page", () => { click(role("button", { name: "Install" })), Command.expectExact(InstallBot({ botId })), expectView(role("button", { name: "Installing..." })).toBeDisabled(), - Command.resolve(InstallBot, Message.FailedInstallBot({ botId, toast })), + Command.resolve(InstallBot, Message.FailedInstallBot({ botId, reason: "AlreadyInstalled" })), expectOutMessage(PageOutMessage.RequestedToast({ toast })), expectView(role("button", { name: "Install" })).toBeEnabled(), ) diff --git a/apps/web-foldkit/src/page/settings/integrations/marketplace/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/marketplace/story.test.ts index fc6515c2f..a50a182a0 100644 --- a/apps/web-foldkit/src/page/settings/integrations/marketplace/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/marketplace/story.test.ts @@ -3,8 +3,8 @@ import { BotId } from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { failureToastFixture } from "../../../../test/pages-fixtures" import { botId } from "../../../../test/pages-integrations-fixtures" +import { reasonToast } from "../../../../data/actions" import { PageOutMessage } from "../../../out-message" import { Message } from "./message" import { InstallBot, init, update } from "./update" @@ -19,8 +19,8 @@ describe("marketplace installs", () => { update, given(init().model), message(Message.ClickedInstall({ botId })), - Command.resolve(InstallBot, Message.FailedInstallBot({ botId, toast: failureToastFixture })), - expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), + Command.resolve(InstallBot, Message.FailedInstallBot({ botId, reason: "RateLimited" })), + expectOutMessage(PageOutMessage.RequestedToast({ toast: reasonToast("RateLimited") })), message(Message.ClickedInstall({ botId: otherBotId })), model((current) => expect(current.installingBotIds).toEqual([otherBotId])), Command.resolve(InstallBot, Message.SucceededInstallBot({ botId: otherBotId })), diff --git a/apps/web-foldkit/src/page/settings/integrations/marketplace/update.ts b/apps/web-foldkit/src/page/settings/integrations/marketplace/update.ts index 6e07a877d..9d9471974 100644 --- a/apps/web-foldkit/src/page/settings/integrations/marketplace/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/marketplace/update.ts @@ -1,49 +1,39 @@ import { BotId } from "@hazel/schema" -import { Effect, Exit } from "effect" +import { Effect } from "effect" import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import * as Interaction from "../../../../ui/aria/interaction" import { HazelRpc } from "../../../../rpc" import type { PageReturn } from "../../../contract" import { PageOutMessage } from "../../../out-message" -import { embedInteraction } from "../shared/interaction" -import { failureToast, rateLimitMessage, successToast } from "../../../../data/actions" +import { catchCommon, errorToastOf, successToast } from "../../../../data/actions" +import type { PageSubscriptionInput } from "../../../contract" +import { failureMessage } from "~/lib/error-messages" import { Message } from "./message" import type { Model } from "./model" -/** `installBotMutation` with the toasts of `handleInstall`. */ +/** `installBotMutation` */ export const InstallBot = Command.define("InstallBot", { args: { botId: BotId }, messages: [Message.SucceededInstallBot, Message.FailedInstallBot], execute: ({ botId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("bot.install", { botId })) - return Exit.isSuccess(exit) - ? Message.SucceededInstallBot({ botId }) - : Message.FailedInstallBot({ - botId, - toast: failureToast(exit.cause, "exitToast", { - BotNotFoundError: () => ({ - title: "Application not found", - description: "This application may no longer be available.", - isRetryable: false, - }), - BotAlreadyInstalledError: () => ({ - title: "Already installed", - description: "This application is already installed in your workspace.", - isRetryable: false, - }), - RateLimitExceededError: rateLimitMessage, - }), - }) - }), + HazelRpc.use((rpc) => rpc("bot.install", { botId })).pipe( + Effect.as(Message.SucceededInstallBot({ botId })), + Effect.catchTags({ + BotNotFoundError: () => + Effect.succeed(Message.FailedInstallBot({ botId, reason: "NotFound" })), + BotAlreadyInstalledError: () => + Effect.succeed(Message.FailedInstallBot({ botId, reason: "AlreadyInstalled" })), + }), + catchCommon((reason) => Message.FailedInstallBot({ botId, reason })), + ), }) type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) /** The interaction target of a bot card's Install button. */ @@ -86,9 +76,22 @@ export const update = (model: Model, message: Message): Return => toast: successToast("Application installed successfully"), }), }), - FailedInstallBot: ({ botId, toast }) => ({ + FailedInstallBot: ({ botId, reason }) => ({ model: doneInstalling(model, botId), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Application not found", + description: "This application may no longer be available.", + }, + AlreadyInstalled: { + title: "Already installed", + description: "This application is already installed in your workspace.", + }, + }), + ), + }), }), GotInteractionMessage: ({ message }) => interaction.fold(model, message), }) diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts index de9681dd4..3f7c0f4ae 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts @@ -53,9 +53,16 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: deploy.id, message: Menu.Message.ClickedItem({ key: "regenerate" }) })), + message( + Message.GotMenuMessage({ + botId: deploy.id, + message: Menu.Message.ClickedItem({ key: "regenerate" }), + }), + ), expectOutMessage( - PageOutMessage.RequestedModal({ modal: { _tag: "RegenerateBotToken", botId: deploy.id, botName: "Deploy Bot" } }), + PageOutMessage.RequestedModal({ + modal: { _tag: "RegenerateBotToken", botId: deploy.id, botName: "Deploy Bot" }, + }), ), model((current) => expect(current.menus[0]?.popup._tag).toBe("Closed")), ) @@ -65,7 +72,12 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: triage.id, message: Menu.Message.ClickedItem({ key: "delete" }) })), + message( + Message.GotMenuMessage({ + botId: triage.id, + message: Menu.Message.ClickedItem({ key: "delete" }), + }), + ), expectNoOutMessage(), model((current) => expect(current).toEqual(withOpenMenu)), ) diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts index 49651b73d..00c66d3ee 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts @@ -51,9 +51,14 @@ const foldMenu = (model: Model, botId: BotId, message: Menu.Message): Return => onSome: (menu) => { const result = Menu.update(menu, message) const bot = model.bots?.find((candidate) => candidate.id === botId) - const selected = result.outMessage?._tag === "SelectedItem" && bot ? modalFor(bot, result.outMessage.key) : null + const selected = + result.outMessage?._tag === "SelectedItem" && bot + ? modalFor(bot, result.outMessage.key) + : null return { - ...(selected === null ? {} : { outMessage: PageOutMessage.RequestedModal({ modal: selected }) }), + ...(selected === null + ? {} + : { outMessage: PageOutMessage.RequestedModal({ modal: selected }) }), model: { ...model, menus: model.menus.map((other) => (other.id === menu.id ? result.model : other)), diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/view.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/view.ts index a458c989e..1b6fc1585 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/view.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/view.ts @@ -24,10 +24,7 @@ const createButton = (h: HtmlBuilder<Message>, size?: "md"): Html => size === "md" ? { intent: "primary", size, className: "shrink-0", onPress: Message.ClickedCreateApplication() } : { intent: "primary", onPress: Message.ClickedCreateApplication() }, - [ - IconPlus(h, { attributes: { "data-slot": "icon" } }), - "Create Application", - ], + [IconPlus(h, { attributes: { "data-slot": "icon" } }), "Create Application"], ) export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => diff --git a/apps/web-foldkit/src/page/settings/invitations/message.ts b/apps/web-foldkit/src/page/settings/invitations/message.ts index 4715d9763..1bf3b2505 100644 --- a/apps/web-foldkit/src/page/settings/invitations/message.ts +++ b/apps/web-foldkit/src/page/settings/invitations/message.ts @@ -1,14 +1,17 @@ import { InvitationId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" import * as Menu from "../../../ui/menu" -import { Invitation } from "./model" +import { Invitation, RevokeFailure } from "./model" export const Message = defineMessageUnion({ - CompletedFetchInvitations: { version: Schema.Number, invitations: Schema.Array(Invitation) }, + SucceededFetchInvitations: { version: Schema.Number, invitations: Schema.Array(Invitation) }, + FailedFetchInvitations: { version: Schema.Number, reason: CommonReason }, + ClickedRetryFetchInvitations: {}, ClickedInviteUser: {}, GotRowMenuMessage: { invitationId: InvitationId, message: Menu.Message }, SucceededRevokeInvitation: {}, - FailedRevokeInvitation: {}, + FailedRevokeInvitation: { reason: RevokeFailure }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/settings/invitations/model.ts b/apps/web-foldkit/src/page/settings/invitations/model.ts index 8c0d62ef1..9d0bcb02d 100644 --- a/apps/web-foldkit/src/page/settings/invitations/model.ts +++ b/apps/web-foldkit/src/page/settings/invitations/model.ts @@ -1,6 +1,8 @@ import { InvitationRole } from "@hazel/domain/rpc" import { InvitationId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" +import { CommonReason } from "~/lib/error-messages" import * as Menu from "../../../ui/menu" export const Invitation = Schema.Struct({ @@ -11,13 +13,21 @@ export const Invitation = Schema.Struct({ }) export type Invitation = typeof Invitation.Type +/** The pending invitations; `invitation.list` fails only with the shared reasons. */ +export const Invitations = AsyncData.Schema(Schema.Array(Invitation), CommonReason) +export type Invitations = typeof Invitations.schema.Type + +/** Why a revoke failed, beyond the shared reasons. */ +export const RevokeFailure = Schema.Union([Schema.Literal("NotFound"), CommonReason]) +export type RevokeFailure = typeof RevokeFailure.Type + export const RowMenu = Schema.Struct({ invitationId: InvitationId, menu: Menu.Model }) export type RowMenu = typeof RowMenu.Type export const Model = Schema.Struct({ /** The route's organization; `null` until it has loaded. */ organizationId: Schema.NullOr(OrganizationId), - invitations: Schema.Array(Invitation), + invitations: Invitations.schema, /** Bumped per fetch; an older result (initial fetch racing a post-revoke refetch) is dropped. */ fetchVersion: Schema.Number, menus: Schema.Array(RowMenu), diff --git a/apps/web-foldkit/src/page/settings/invitations/page.test.ts b/apps/web-foldkit/src/page/settings/invitations/page.test.ts index 3392eae11..d1fc1c215 100644 --- a/apps/web-foldkit/src/page/settings/invitations/page.test.ts +++ b/apps/web-foldkit/src/page/settings/invitations/page.test.ts @@ -9,13 +9,15 @@ import type { Invitation } from "./model" import { init, REVOKE_KEY, update } from "./update" const inv1 = Schema.decodeSync(InvitationId)(uuid(11)) -const inv2 = Schema.decodeSync(InvitationId)(uuid(12)) /** Update-loop tests for pending invitations: row menus and the revoke lifecycle. */ const invitation: Invitation = { id: inv1, emailAddress: "ada@example.com", role: "member", createdAtMs: 0 } const loaded = () => - update(init(undefined, makeShared()).model, Message.CompletedFetchInvitations({ version: 1, invitations: [invitation] })).model + update( + init(undefined, makeShared()).model, + Message.SucceededFetchInvitations({ version: 1, invitations: [invitation] }), + ).model describe("pending invitations", () => { test("every invitation gets a row menu with a revoke item", () => { @@ -32,9 +34,9 @@ describe("pending invitations", () => { expect(done.model.revokingId).toBeNull() expect(done.commands?.map((command) => command.name)).toEqual(["FetchInvitations"]) expect(done.outMessage).toMatchObject({ toast: { title: "Invitation revoked successfully" } }) - const failed = update(revoking, Message.FailedRevokeInvitation()) + const failed = update(revoking, Message.FailedRevokeInvitation({ reason: "Unexpected" })) expect(failed.outMessage).toMatchObject({ - toast: { intent: "error", title: "Failed to revoke invitation" }, + toast: { intent: "error", title: "Something went wrong" }, }) }) diff --git a/apps/web-foldkit/src/page/settings/invitations/scene.test.ts b/apps/web-foldkit/src/page/settings/invitations/scene.test.ts index c0fb689a6..4e398a2a3 100644 --- a/apps/web-foldkit/src/page/settings/invitations/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/invitations/scene.test.ts @@ -3,8 +3,9 @@ import { InvitationId } from "@hazel/schema" import { Schema } from "effect" import * as Scene from "foldkit/scene" import { describe, test } from "vitest" +import type { CommonReason } from "~/lib/error-messages" import { errorToast, successToast } from "../../../data/actions" -import {makeShared,pageScene,uuid} from "../../../test/pages-fixtures" +import { makeShared, organizationId, pageScene, uuid } from "../../../test/pages-fixtures" import * as Menu from "../../../ui/menu" import { FocusTriggerOnPress } from "../../../ui/menu-view" import { PageOutMessage } from "../../out-message" @@ -14,7 +15,6 @@ import { FetchInvitations, init, RevokeInvitation, update } from "./update" import { view } from "./view" const inv1 = Schema.decodeSync(InvitationId)(uuid(11)) -const inv2 = Schema.decodeSync(InvitationId)(uuid(12)) /** The pending invitations table through its view: empty state, the row menu and revoking. */ @@ -22,7 +22,7 @@ const config = pageScene(update, view, makeShared()) const ada: Invitation = { id: inv1, emailAddress: "ada@hazel.test", role: "member", createdAtMs: 0 } const loaded = update( init(undefined, makeShared()).model, - Message.CompletedFetchInvitations({ version: 1, invitations: [ada] }), + Message.SucceededFetchInvitations({ version: 1, invitations: [ada] }), ).model // The row trigger is an icon-only button with no accessible name, so it is located by id. @@ -36,11 +36,73 @@ const openRowMenu = [ Scene.expect(revokeItem).toExist(), ] +const loadedEmpty = update( + init(undefined, makeShared()).model, + Message.SucceededFetchInvitations({ version: 1, invitations: [] }), +).model + +const failedWith = (reason: CommonReason) => + update(init(undefined, makeShared()).model, Message.FailedFetchInvitations({ version: 1, reason })).model + +describe("loading the list", () => { + test("while the first fetch runs the page shows a loader, not the empty state", () => { + Scene.scene( + config, + Scene.given(init(undefined, makeShared()).model), + Scene.expect(Scene.text("Loading invitations...")).toExist(), + Scene.expect(Scene.role("heading", { name: "No pending invitations" })).toBeAbsent(), + ) + }) + + test("a failed fetch shows the error and a retry, never the empty state", () => { + Scene.scene( + config, + Scene.given(failedWith("Offline")), + Scene.expect(Scene.role("heading", { name: "Couldn't load invitations" })).toExist(), + Scene.expect(Scene.text("Check your internet connection and try again.")).toExist(), + Scene.expect(Scene.role("heading", { name: "No pending invitations" })).toBeAbsent(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(FetchInvitations({ organizationId, version: 2 })), + Scene.expect(Scene.text("Loading invitations...")).toExist(), + Scene.Command.resolve( + FetchInvitations, + Message.SucceededFetchInvitations({ version: 2, invitations: [ada] }), + ), + triggerMounted, + Scene.expect(Scene.text("ada@hazel.test")).toExist(), + ) + }) + + test("an unexpected failure offers no retry", () => { + Scene.scene( + config, + Scene.given(failedWith("Unexpected")), + Scene.expect(Scene.role("heading", { name: "Couldn't load invitations" })).toExist(), + Scene.expect(Scene.role("button", { name: "Try again" })).toBeAbsent(), + ) + }) + + test("a failed refetch keeps the loaded rows and says it couldn't refresh", () => { + Scene.scene( + config, + Scene.given( + update( + { ...loaded, fetchVersion: 2 }, + Message.FailedFetchInvitations({ version: 2, reason: "Timeout" }), + ).model, + ), + triggerMounted, + Scene.expect(Scene.text("ada@hazel.test")).toExist(), + Scene.expect(Scene.role("alert")).toContainText("Couldn't refresh"), + ) + }) +}) + describe("empty state", () => { test("with no invitations both invite buttons request the invite modal", () => { Scene.scene( config, - Scene.given(init(undefined, makeShared()).model), + Scene.given(loadedEmpty), Scene.expect(Scene.role("heading", { name: "No pending invitations" })).toExist(), Scene.click(Scene.role("button", { name: /Invite a team member/ })), Scene.expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "EmailInvite" } })), @@ -70,7 +132,7 @@ describe("revoke from the row menu", () => { ), Scene.Command.resolve( FetchInvitations, - Message.CompletedFetchInvitations({ version: 2, invitations: [] }), + Message.SucceededFetchInvitations({ version: 2, invitations: [] }), ), Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.expect(Scene.text("ada@hazel.test")).toBeAbsent(), @@ -86,14 +148,16 @@ describe("revoke from the row menu", () => { ...openRowMenu, Scene.click(revokeItem), Scene.Mount.expectEnded(portalMenu), - Scene.Command.resolve(RevokeInvitation, Message.FailedRevokeInvitation()), + Scene.Command.resolve(RevokeInvitation, Message.FailedRevokeInvitation({ reason: "Unexpected" })), Scene.expectOutMessage( - PageOutMessage.RequestedToast({ toast: errorToast("Failed to revoke invitation") }), + PageOutMessage.RequestedToast({ + toast: errorToast("Something went wrong", "Please try again."), + }), ), Scene.expect(trigger).toBeEnabled(), Scene.Command.resolve( FetchInvitations, - Message.CompletedFetchInvitations({ version: 2, invitations: [ada] }), + Message.SucceededFetchInvitations({ version: 2, invitations: [ada] }), ), Scene.expect(Scene.text("ada@hazel.test")).toExist(), ) diff --git a/apps/web-foldkit/src/page/settings/invitations/story.test.ts b/apps/web-foldkit/src/page/settings/invitations/story.test.ts index a5341ecf1..9c70bf5e8 100644 --- a/apps/web-foldkit/src/page/settings/invitations/story.test.ts +++ b/apps/web-foldkit/src/page/settings/invitations/story.test.ts @@ -4,12 +4,12 @@ import { Schema } from "effect" import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { errorToast, successToast } from "../../../data/actions" -import {makeShared,storyUpdate,uuid} from "../../../test/pages-fixtures" +import { makeShared, storyUpdate, uuid } from "../../../test/pages-fixtures" import * as Menu from "../../../ui/menu" import { PageOutMessage } from "../../out-message" import { Message } from "./message" import type { Invitation } from "./model" -import { FetchInvitations, init, REVOKE_KEY, RevokeInvitation, update } from "./update" +import { FetchInvitations, init, invitationsOf, REVOKE_KEY, RevokeInvitation, update } from "./update" const inv1 = Schema.decodeSync(InvitationId)(uuid(11)) const inv2 = Schema.decodeSync(InvitationId)(uuid(12)) @@ -21,7 +21,7 @@ const ada: Invitation = { id: inv1, emailAddress: "ada@hazel.test", role: "membe const grace: Invitation = { id: inv2, emailAddress: "grace@hazel.test", role: "admin", createdAtMs: 0 } const loaded = update( init(undefined, makeShared()).model, - Message.CompletedFetchInvitations({ version: 1, invitations: [ada, grace] }), + Message.SucceededFetchInvitations({ version: 1, invitations: [ada, grace] }), ).model const rowMenu = (invitationId: InvitationId, child: Menu.Message) => @@ -39,13 +39,13 @@ const revokeDisabled = (invitationId: InvitationId) => describe("fetch", () => { test("an older fetch result is dropped once a newer fetch is out", () => { const refetching = { ...loaded, fetchVersion: 2 } - const stale = update(refetching, Message.CompletedFetchInvitations({ version: 1, invitations: [] })) - expect(stale.model.invitations).toEqual([ada, grace]) + const stale = update(refetching, Message.SucceededFetchInvitations({ version: 1, invitations: [] })) + expect(invitationsOf(stale.model)).toEqual([ada, grace]) const fresh = update( refetching, - Message.CompletedFetchInvitations({ version: 2, invitations: [grace] }), + Message.SucceededFetchInvitations({ version: 2, invitations: [grace] }), ) - expect(fresh.model.invitations).toEqual([grace]) + expect(invitationsOf(fresh.model)).toEqual([grace]) }) test("init fetches the pending invitations, and the result builds one closed menu per row", () => { @@ -54,13 +54,15 @@ describe("fetch", () => { given(init(undefined, makeShared()).model), message(Message.ClickedInviteUser()), expectOutMessage(PageOutMessage.RequestedModal({ modal: { _tag: "EmailInvite" } })), - message(Message.CompletedFetchInvitations({ version: 1, invitations: [ada, grace] })), + message(Message.SucceededFetchInvitations({ version: 1, invitations: [ada, grace] })), model((current) => { expect(current.menus.map((row) => row.invitationId)).toEqual([inv1, inv2]) expect(current.menus.every((row) => row.menu.popup._tag === "Closed")).toBe(true) }), ) - expect(init(undefined, makeShared()).commands?.map((command) => command.name)).toEqual([FetchInvitations.name]) + expect(init(undefined, makeShared()).commands?.map((command) => command.name)).toEqual([ + FetchInvitations.name, + ]) }) test("a refetch keeps the open menu of a row that stays and drops rows that left", () => { @@ -68,7 +70,7 @@ describe("fetch", () => { run, given(loaded), openMenu(inv2), - message(Message.CompletedFetchInvitations({ version: 1, invitations: [grace] })), + message(Message.SucceededFetchInvitations({ version: 1, invitations: [grace] })), model((current) => { expect(current.menus).toHaveLength(1) expect(current.menus[0]?.menu.popup._tag).toBe("Open") @@ -104,9 +106,9 @@ describe("revoke", () => { model((current) => expect(current.revokingId).toBeNull()), Command.resolve( FetchInvitations, - Message.CompletedFetchInvitations({ version: 2, invitations: [grace] }), + Message.SucceededFetchInvitations({ version: 2, invitations: [grace] }), ), - model((current) => expect(current.invitations).toEqual([grace])), + model((current) => expect(invitationsOf(current)).toEqual([grace])), ) }) @@ -116,9 +118,11 @@ describe("revoke", () => { given(loaded), openMenu(inv1), pickRevoke(inv1), - Command.resolve(RevokeInvitation, Message.FailedRevokeInvitation()), + Command.resolve(RevokeInvitation, Message.FailedRevokeInvitation({ reason: "Unexpected" })), expectOutMessage( - PageOutMessage.RequestedToast({ toast: errorToast("Failed to revoke invitation") }), + PageOutMessage.RequestedToast({ + toast: errorToast("Something went wrong", "Please try again."), + }), ), model((current) => { expect(current.revokingId).toBeNull() @@ -128,7 +132,7 @@ describe("revoke", () => { }), Command.resolve( FetchInvitations, - Message.CompletedFetchInvitations({ version: 2, invitations: [ada, grace] }), + Message.SucceededFetchInvitations({ version: 2, invitations: [ada, grace] }), ), ) }) diff --git a/apps/web-foldkit/src/page/settings/invitations/update.ts b/apps/web-foldkit/src/page/settings/invitations/update.ts index 2f6d2bc57..9a299fe12 100644 --- a/apps/web-foldkit/src/page/settings/invitations/update.ts +++ b/apps/web-foldkit/src/page/settings/invitations/update.ts @@ -1,15 +1,16 @@ -import { Array, Effect, Option, Schema } from "effect" +import { InvitationId, OrganizationId } from "@hazel/schema" +import { Array, Effect, Option, Result, Schema } from "effect" import { Command } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../../data/actions" +import { listInvitations, revokeInvitation } from "../../../data/invitations" import * as Menu from "../../../ui/menu" -import type { PageReturn } from "../../contract" +import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { errorToast, successToast } from "../../../data/actions" -import { InvitationId, OrganizationId } from "@hazel/schema" -import { listInvitations, revokeInvitation } from "../../../data/invitations" -import type { Shared } from "../../contract" import { Message } from "./message" -import type { Invitation, Model, RowMenu } from "./model" +import { type Invitation, Invitations, type Model, type RowMenu } from "./model" type Return = PageReturn<Model, Message> @@ -19,12 +20,11 @@ export const REVOKE_KEY = "revoke" export const FetchInvitations = Command.define("FetchInvitations", { args: { organizationId: OrganizationId, version: Schema.Number }, - messages: [Message.CompletedFetchInvitations], + messages: [Message.SucceededFetchInvitations, Message.FailedFetchInvitations], execute: ({ organizationId, version }) => listInvitations(organizationId).pipe( - // The page shows the empty state when the list can't be loaded. - Effect.orElseSucceed(() => []), - Effect.map((invitations) => Message.CompletedFetchInvitations({ version, invitations })), + Effect.map((invitations) => Message.SucceededFetchInvitations({ version, invitations })), + catchCommon((reason) => Message.FailedFetchInvitations({ version, reason })), ), }) @@ -33,10 +33,12 @@ export const RevokeInvitation = Command.define("RevokeInvitation", { messages: [Message.SucceededRevokeInvitation, Message.FailedRevokeInvitation], execute: ({ invitationId }) => revokeInvitation(invitationId).pipe( - Effect.match({ - onSuccess: () => Message.SucceededRevokeInvitation(), - onFailure: () => Message.FailedRevokeInvitation(), + Effect.as(Message.SucceededRevokeInvitation()), + Effect.catchTags({ + InvitationNotFoundError: () => + Effect.succeed(Message.FailedRevokeInvitation({ reason: "NotFound" })), }), + catchCommon((reason) => Message.FailedRevokeInvitation({ reason })), ), }) @@ -46,6 +48,10 @@ const menuEntries = (isRevoking: boolean) => [ Menu.item(REVOKE_KEY, { intent: "Danger", isDisabled: isRevoking }), ] +/** The invitations on screen: the loaded list, kept while a refetch runs or after one failed. */ +export const invitationsOf = (model: Model): ReadonlyArray<Invitation> => + Option.getOrElse(AsyncData.getData(model.invitations), () => []) + /** One row menu per invitation, keeping open state for rows that stay. */ const reflectMenus = ( menus: ReadonlyArray<RowMenu>, @@ -72,7 +78,7 @@ const reflectMenus = ( const withRevokingId = (model: Model, revokingId: InvitationId | null): Model => modifyFields(model, { revokingId: () => revokingId, - menus: (menus) => reflectMenus(menus, model.invitations, revokingId), + menus: (menus) => reflectMenus(menus, invitationsOf(model), revokingId), }) // INIT @@ -80,7 +86,13 @@ const withRevokingId = (model: Model, revokingId: InvitationId | null): Model => export const init = (_route: unknown, shared: Shared): Return => { const organizationId = shared.organization?.id ?? null return { - model: { organizationId, invitations: [], fetchVersion: 1, menus: [], revokingId: null }, + model: { + organizationId, + invitations: organizationId === null ? Invitations.Idle() : Invitations.Loading(), + fetchVersion: 1, + menus: [], + revokingId: null, + }, commands: organizationId === null ? [] : [FetchInvitations({ organizationId, version: 1 })], } } @@ -116,28 +128,41 @@ const updateRowMenu = (model: Model, invitationId: InvitationId, message: Menu.M }, ) +/** Fetches again under a new version; loaded rows stay on screen while it runs. */ const refetch = (model: Model): Return => { + if (model.organizationId === null) return { model } const version = model.fetchVersion + 1 return { - model: modifyFields(model, { fetchVersion: () => version }), - commands: - model.organizationId === null - ? [] - : [FetchInvitations({ organizationId: model.organizationId, version })], + model: modifyFields(model, { + fetchVersion: () => version, + invitations: (invitations) => + Option.getOrElse(AsyncData.revalidateOrLoad(invitations), () => invitations), + }), + commands: [FetchInvitations({ organizationId: model.organizationId, version })], } } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - CompletedFetchInvitations: ({ version, invitations }) => + SucceededFetchInvitations: ({ version, invitations }) => version !== model.fetchVersion ? { model } : { model: modifyFields(model, { - invitations: () => invitations, + invitations: () => Invitations.Success({ data: invitations }), menus: (menus) => reflectMenus(menus, invitations, model.revokingId), }), }, + // A failed refetch keeps the rows it had (`Stale`); a failed first load shows the error. + FailedFetchInvitations: ({ version, reason }) => + version !== model.fetchVersion + ? { model } + : { + model: modifyFields(model, { + invitations: (invitations) => AsyncData.settle(invitations, Result.fail(reason)), + }), + }, + ClickedRetryFetchInvitations: () => refetch(model), ClickedInviteUser: () => ({ model, outMessage: PageOutMessage.RequestedModal({ modal: { _tag: "EmailInvite" } }), @@ -150,8 +175,17 @@ export const update = (model: Model, message: Message): Return => toast: successToast("Invitation revoked successfully"), }), }), - FailedRevokeInvitation: () => ({ + FailedRevokeInvitation: ({ reason }) => ({ ...refetch(withRevokingId(model, null)), - outMessage: PageOutMessage.RequestedToast({ toast: errorToast("Failed to revoke invitation") }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Invitation not found", + description: "It may have already been accepted or revoked.", + }, + }), + ), + }), }), }) diff --git a/apps/web-foldkit/src/page/settings/invitations/view.ts b/apps/web-foldkit/src/page/settings/invitations/view.ts index 387747974..202699fcc 100644 --- a/apps/web-foldkit/src/page/settings/invitations/view.ts +++ b/apps/web-foldkit/src/page/settings/invitations/view.ts @@ -1,15 +1,19 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" -import { IconClose, IconDots, IconPlus, IconUsersPlus } from "../../../icons" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" +import { IconClose, IconDots, IconPlus, IconUsersPlus, IconWarning } from "../../../icons" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" import { card, cardHeader, cardHeaderGroup } from "../../../ui/card" import { emptyState } from "../../../ui/empty-state" +import { loader } from "../../../ui/loader" import * as Menu from "../../../ui/menu" import { menuLabel, view as menuView } from "../../../ui/menu-view" import type { PageViewInputs } from "../../contract" import { Message } from "./message" import type { Invitation, Model, RowMenu } from "./model" +import { invitationsOf } from "./update" /** Port of `routes/_app/$orgSlug/settings/invitations.tsx`. */ @@ -35,10 +39,17 @@ const rowActions = (h: HtmlBuilder<Message>, row: RowMenu, isRevoking: boolean): view: menuView, viewInputs: { toTrigger: (attributes, overlay) => - button(h, { intent: "plain", size: "sq-xs", isPending: isRevoking, isDisabled: isRevoking, attributes }, [ - IconDots(h), - overlay, - ]), + button( + h, + { + intent: "plain", + size: "sq-xs", + isPending: isRevoking, + isDisabled: isRevoking, + attributes, + }, + [IconDots(h), overlay], + ), content: (key) => [ IconClose(h, { attributes: { "data-slot": "icon" } }), menuLabel(h, row.menu.id, key, isRevoking ? "Revoking..." : "Revoke Invitation"), @@ -54,13 +65,27 @@ const invitationRow = (h: HtmlBuilder<Message>, model: Model, invitation: Invita invitation.id, [h.Class("hover:bg-secondary/50")], [ - h.td([h.Class("px-4 py-4")], [h.p([h.Class("font-medium text-fg text-sm")], [invitation.emailAddress])]), - h.td([h.Class("px-4 py-4")], [h.p([h.Class("text-muted-fg text-sm")], [roleLabel(invitation.role)])]), h.td( [h.Class("px-4 py-4")], - [badge(h, { intent: "warning" }, [h.span([h.Class("size-1.5 rounded-full bg-current")]), "Pending"])], + [h.p([h.Class("font-medium text-fg text-sm")], [invitation.emailAddress])], + ), + h.td( + [h.Class("px-4 py-4")], + [h.p([h.Class("text-muted-fg text-sm")], [roleLabel(invitation.role)])], + ), + h.td( + [h.Class("px-4 py-4")], + [ + badge(h, { intent: "warning" }, [ + h.span([h.Class("size-1.5 rounded-full bg-current")]), + "Pending", + ]), + ], + ), + h.td( + [h.Class("px-4 py-4")], + [h.p([h.Class("text-muted-fg text-sm")], [formatSent(invitation.createdAtMs)])], ), - h.td([h.Class("px-4 py-4")], [h.p([h.Class("text-muted-fg text-sm")], [formatSent(invitation.createdAtMs)])]), h.td( [h.Class("px-4 py-4 text-right")], row === undefined ? [] : [rowActions(h, row, model.revokingId === invitation.id)], @@ -69,7 +94,11 @@ const invitationRow = (h: HtmlBuilder<Message>, model: Model, invitation: Invita ) } -const invitationsTable = (h: HtmlBuilder<Message>, model: Model): Html => +const invitationsTable = ( + h: HtmlBuilder<Message>, + model: Model, + invitations: ReadonlyArray<Invitation>, +): Html => h.div( [h.Class("overflow-x-auto")], [ @@ -93,15 +122,81 @@ const invitationsTable = (h: HtmlBuilder<Message>, model: Model): Html => ), h.tbody( [h.Class("divide-y divide-border")], - model.invitations.map((invitation) => invitationRow(h, model, invitation)), + invitations.map((invitation) => invitationRow(h, model, invitation)), ), ], ), ], ) +const retryButton = (h: HtmlBuilder<Message>, reason: CommonReason): ReadonlyArray<Html> => + isRetryableReason(reason) + ? [ + button( + h, + { intent: "secondary", size: "sm", onPress: Message.ClickedRetryFetchInvitations() }, + ["Try again"], + ), + ] + : [] + +const loadingState = (h: HtmlBuilder<Message>): Html => + h.div( + [h.Class("flex h-64 items-center justify-center gap-3 text-muted-fg")], + [loader(h, { className: "size-6" }), h.span([h.Class("text-sm")], ["Loading invitations..."])], + ) + +/** The first load failed: nothing to show but the reason. */ +const failedState = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return emptyState(h, { + icon: (className) => IconWarning(h, { className }), + title: "Couldn't load invitations", + description: message.description ?? message.title, + action: retryButton(h, reason), + className: "h-64", + }) +} + +/** A refetch failed: the last loaded rows stay, with the reason above them. */ +const staleNotice = (h: HtmlBuilder<Message>, reason: CommonReason): Html => + h.div( + [ + h.Class("flex items-center justify-between gap-3 border-border border-b px-4 py-3"), + h.Role("alert"), + ], + [ + h.p([h.Class("text-danger text-sm")], [`Couldn't refresh: ${commonMessage(reason).title}`]), + ...retryButton(h, reason), + ], + ) + +const loadedBody = (h: HtmlBuilder<Message>, model: Model, invitations: ReadonlyArray<Invitation>): Html => + invitations.length === 0 + ? emptyState(h, { + icon: (className) => IconUsersPlus(h, { className }), + title: "No pending invitations", + description: "Invite team members to join your organization.", + action: button(h, { intent: "secondary", size: "sm", onPress: Message.ClickedInviteUser() }, [ + plusIcon(h), + "Invite a team member", + ]), + className: "h-64", + }) + : invitationsTable(h, model, invitations) + +const body = (h: HtmlBuilder<Message>, model: Model): Html => + AsyncData.match(model.invitations, { + onIdle: () => loadingState(h), + onLoading: () => loadingState(h), + onFailure: (reason) => failedState(h, reason), + onRefreshing: (invitations) => loadedBody(h, model, invitations), + onStale: ({ error, data }) => h.div([], [staleNotice(h, error), loadedBody(h, model, data)]), + onSuccess: (invitations) => loadedBody(h, model, invitations), + }) + export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => { - const count = model.invitations.length + const count = invitationsOf(model).length return h.div( [h.Class("flex flex-col gap-6 px-4 lg:px-8")], [ @@ -114,8 +209,13 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, h.div( [h.Class("flex items-center gap-2")], [ - h.h2([h.Class("font-semibold text-fg text-lg")], ["Pending invitations"]), - ...(count > 0 ? [badge(h, { intent: "secondary" }, [`${count}`, " pending"])] : []), + h.h2( + [h.Class("font-semibold text-fg text-lg")], + ["Pending invitations"], + ), + ...(count > 0 + ? [badge(h, { intent: "secondary" }, [`${count}`, " pending"])] + : []), ], ), h.p( @@ -129,27 +229,16 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, h.div( [h.Class("flex gap-3")], [ - button(h, { intent: "secondary", size: "md", onPress: Message.ClickedInviteUser() }, [ - plusIcon(h), - "Invite user", - ]), + button( + h, + { intent: "secondary", size: "md", onPress: Message.ClickedInviteUser() }, + [plusIcon(h), "Invite user"], + ), ], ), ]), ]), - count === 0 - ? emptyState(h, { - icon: (className) => IconUsersPlus(h, { className }), - title: "No pending invitations", - description: "Invite team members to join your organization.", - action: button( - h, - { intent: "secondary", size: "sm", onPress: Message.ClickedInviteUser() }, - [plusIcon(h), "Invite a team member"], - ), - className: "h-64", - }) - : invitationsTable(h, model), + body(h, model), ]), ], ) diff --git a/apps/web-foldkit/src/page/settings/team/view.ts b/apps/web-foldkit/src/page/settings/team/view.ts index a5f6d5332..8309e6dd6 100644 --- a/apps/web-foldkit/src/page/settings/team/view.ts +++ b/apps/web-foldkit/src/page/settings/team/view.ts @@ -13,11 +13,6 @@ import type { Model, TeamMember } from "./model" /** Port of `routes/_app/$orgSlug/settings/team.tsx` (read path; menus and modals follow in Phase 5). */ -const getInitials = (name: string) => { - const [firstName, lastName] = name.split(" ") - return `${firstName?.charAt(0)}${lastName?.charAt(0)}` -} - const canManageUser = ( members: ReadonlyArray<TeamMember>, currentUserId: string | undefined, From 046d148e4bf82d34c13f769feeaace4cb6f5774d Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:57:37 +0200 Subject: [PATCH 43/52] fix(web): my-settings form validation copy, typed upload/autostart failures, OAuth error copy Profile uses fieldValidation with real messages, derived dirtiness, DropZone and Update.foldChild; linked accounts maps OAuth error codes; desktop reports a failed autostart change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/overlay/toaster.test.ts | 6 +- .../src/page/my-settings/appearance/update.ts | 12 +- .../src/page/my-settings/desktop/message.ts | 8 +- .../page/my-settings/desktop/scene.test.ts | 36 ++- .../src/page/my-settings/desktop/update.ts | 58 +++- .../src/page/my-settings/desktop/view.ts | 111 ++++--- .../my-settings/linked-accounts/command.ts | 38 ++- .../linked-accounts/linked-accounts.test.ts | 19 +- .../my-settings/linked-accounts/message.ts | 16 +- .../page/my-settings/linked-accounts/model.ts | 15 +- .../my-settings/linked-accounts/scene.test.ts | 15 +- .../my-settings/linked-accounts/story.test.ts | 56 +++- .../linked-accounts/subscription.ts | 3 +- .../my-settings/linked-accounts/update.ts | 85 +++-- .../page/my-settings/linked-accounts/view.ts | 10 +- .../page/my-settings/notifications/command.ts | 38 ++- .../page/my-settings/notifications/message.ts | 11 +- .../page/my-settings/notifications/model.ts | 6 +- .../my-settings/notifications/scene.test.ts | 21 +- .../my-settings/notifications/story.test.ts | 62 +++- .../page/my-settings/notifications/update.ts | 90 +++--- .../page/my-settings/profile/avatar-view.ts | 54 ++-- .../src/page/my-settings/profile/command.ts | 73 +++-- .../src/page/my-settings/profile/crop.ts | 4 + .../src/page/my-settings/profile/form.ts | 38 +-- .../src/page/my-settings/profile/message.ts | 33 +- .../src/page/my-settings/profile/model.ts | 48 ++- .../my-settings/profile/profile.scene.test.ts | 70 +++- .../my-settings/profile/profile.story.test.ts | 244 +++++++++----- .../page/my-settings/profile/subscription.ts | 40 +-- .../src/page/my-settings/profile/update.ts | 298 ++++++++++-------- .../src/page/my-settings/profile/view.ts | 27 +- apps/web-foldkit/src/page/my-settings/user.ts | 29 +- 33 files changed, 1088 insertions(+), 586 deletions(-) diff --git a/apps/web-foldkit/src/overlay/toaster.test.ts b/apps/web-foldkit/src/overlay/toaster.test.ts index aa9d50846..a52bb1119 100644 --- a/apps/web-foldkit/src/overlay/toaster.test.ts +++ b/apps/web-foldkit/src/overlay/toaster.test.ts @@ -53,7 +53,11 @@ describe("root toaster", () => { ), }) expect(booted.model.toasts.toaster.toasts).toMatchObject([ - { kind: "error", title: "Failed to link Discord account", description: "db_error" }, + { + kind: "error", + title: "Failed to link Discord account", + description: "A database error occurred. Please try again.", + }, ]) // The kit's own Messages fold back into the slot through `GotToastsMessage`. const hovered = update( diff --git a/apps/web-foldkit/src/page/my-settings/appearance/update.ts b/apps/web-foldkit/src/page/my-settings/appearance/update.ts index 959eb0e8d..654ee3542 100644 --- a/apps/web-foldkit/src/page/my-settings/appearance/update.ts +++ b/apps/web-foldkit/src/page/my-settings/appearance/update.ts @@ -5,9 +5,8 @@ import { modifyFields } from "foldkit/struct" import { GRAY_PALETTE_LABELS, getBuiltInPreset } from "~/lib/theme/presets" import * as Interaction from "../../../ui/aria/interaction" import * as Select from "../../../ui/select" -import type { PageReturn, Shared } from "../../contract" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { embedInteraction } from "../shared" import { GenerateRemixOptions } from "./command" import { Message } from "./message" import type { Customization, Model, ThemeMode } from "./model" @@ -17,8 +16,9 @@ type Return = PageReturn<Model, Message> export const GENERATE_TARGET = "remix-generate" -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) export const toGrayPaletteMessage = (message: Select.Message) => Message.GotGrayPaletteMessage({ message }) @@ -68,7 +68,9 @@ export const init = (_route: unknown, shared: Shared): Return => ({ }, }) -export const sharedChanged = (model: Model, shared: Shared): Return => ({ model: withGrayPalette(model, shared) }) +export const sharedChanged = (model: Model, shared: Shared): Return => ({ + model: withGrayPalette(model, shared), +}) export const update = (model: Model, message: Message, shared: Shared): Return => { const { mode, customization } = shared.theme diff --git a/apps/web-foldkit/src/page/my-settings/desktop/message.ts b/apps/web-foldkit/src/page/my-settings/desktop/message.ts index 4481cfe46..16262d298 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/message.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/message.ts @@ -2,10 +2,16 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import * as Interaction from "../../../ui/aria/interaction" +/** The autostart plugin rejected the write. */ +export const SetAutostartFailure = Schema.Literal("PluginFailed") +export type SetAutostartFailure = typeof SetAutostartFailure.Type + export const Message = defineMessageUnion({ CheckedAutostart: { isEnabled: Schema.Boolean }, ToggledAutostart: { isSelected: Schema.Boolean }, - CompletedSetAutostart: { isEnabled: Schema.Boolean }, + SucceededSetAutostart: { isEnabled: Schema.Boolean }, + /** `isEnabled` is the state re-read after the failed write, so the switch shows the truth. */ + FailedSetAutostart: { reason: SetAutostartFailure, isEnabled: Schema.Boolean }, GotInteractionMessage: { message: Interaction.Message }, ReadAppVersion: { version: Schema.NullOr(Schema.String) }, ClickedCheckForUpdates: {}, diff --git a/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts b/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts index a44dfa5f3..f5d394075 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts @@ -2,6 +2,7 @@ import * as Scene from "foldkit/scene" import { describe, expect, test } from "vitest" import { makeShared, pageScene } from "../../../test/pages-fixtures" +import { PageOutMessage } from "../../out-message" import { Message } from "./message" import { CheckAutostart, init, SetAutostart, update } from "./update" import { view } from "./view" @@ -35,28 +36,47 @@ describe("launch at startup", () => { Scene.click(openAtLogin), Scene.Command.expectExact(SetAutostart({ isEnabled: true })), Scene.expect(openAtLogin).not.toBeChecked(), - Scene.Command.resolve(SetAutostart, Message.CompletedSetAutostart({ isEnabled: true })), + Scene.Command.resolve(SetAutostart, Message.SucceededSetAutostart({ isEnabled: true })), Scene.expect(openAtLogin).toBeChecked(), ) }) test("a second toggle while a write runs is ignored, and a late initial read cannot undo the write", () => { - const writing = update({ ...init().model, autostartEnabled: false }, Message.ToggledAutostart({ isSelected: true })) + const writing = update( + { ...init().model, autostartEnabled: false }, + Message.ToggledAutostart({ isSelected: true }), + ) expect(writing.model.isUpdating).toBe(true) - expect(update(writing.model, Message.ToggledAutostart({ isSelected: false })).commands ?? []).toEqual([]) - const written = update(writing.model, Message.CompletedSetAutostart({ isEnabled: true })).model + expect(update(writing.model, Message.ToggledAutostart({ isSelected: false })).commands ?? []).toEqual( + [], + ) + const written = update(writing.model, Message.SucceededSetAutostart({ isEnabled: true })).model expect(written).toMatchObject({ autostartEnabled: true, isUpdating: false }) - expect(update(written, Message.CheckedAutostart({ isEnabled: false })).model.autostartEnabled).toBe(true) + expect(update(written, Message.CheckedAutostart({ isEnabled: false })).model.autostartEnabled).toBe( + true, + ) }) - test("a failed write falls back to the re-read state without a toast", () => { + test("a failed write toasts and falls back to the re-read state", () => { Scene.scene( pageScene(update, view, shared), Scene.given({ ...init().model, autostartEnabled: false }), Scene.click(openAtLogin), - Scene.Command.resolve(SetAutostart, Message.CompletedSetAutostart({ isEnabled: false })), - Scene.expectNoOutMessage(), + Scene.Command.resolve( + SetAutostart, + Message.FailedSetAutostart({ reason: "PluginFailed", isEnabled: false }), + ), + Scene.expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Failed to update launch at login", + description: "Please try again.", + }, + }), + ), Scene.expect(openAtLogin).not.toBeChecked(), + Scene.expect(openAtLogin).toBeEnabled(), ) }) }) diff --git a/apps/web-foldkit/src/page/my-settings/desktop/update.ts b/apps/web-foldkit/src/page/my-settings/desktop/update.ts index 2032b0396..79e0fc235 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/update.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/update.ts @@ -3,21 +3,30 @@ import { Command } from "foldkit" import { modifyFields } from "foldkit/struct" import { getTauriApp } from "@hazel/desktop/bridge" import { disableAutostart, enableAutostart, isAutostartEnabled } from "~/lib/tauri-autostart" +import { errorToast } from "../../../data/actions" import { isDesktop } from "../../../platform/desktop" import { PageOutMessage } from "../../out-message" import * as Interaction from "../../../ui/aria/interaction" -import type { PageReturn } from "../../contract" -import { embedInteraction } from "../shared" +import type { PageReturn, PageSubscriptionInput } from "../../contract" import { Message } from "./message" import type { Model } from "./model" -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) +/** A Tauri plugin call (autostart, app version) rejected. */ +class TauriPluginError extends Schema.TaggedError<TauriPluginError>()("TauriPluginError", { + cause: Schema.Defect(), +}) {} + +const tauri = <A>(run: () => Promise<A>) => + Effect.tryPromise({ try: run, catch: (cause) => new TauriPluginError({ cause }) }) + /** Reads the autostart state; a failure reads as off, as the legacy mount effect does. */ -const readAutostart = Effect.tryPromise(() => isAutostartEnabled()).pipe( - Effect.catch(() => Effect.succeed(false)), +const readAutostart = tauri(isAutostartEnabled).pipe( + Effect.catchTags({ TauriPluginError: () => Effect.succeed(false) }), ) export const CheckAutostart = Command.define("CheckAutostart", { @@ -28,20 +37,26 @@ export const CheckAutostart = Command.define("CheckAutostart", { /** On failure the legacy handler re-reads the real state instead of keeping the toggle. */ export const SetAutostart = Command.define("SetAutostart", { args: { isEnabled: Schema.Boolean }, - messages: [Message.CompletedSetAutostart], + messages: [Message.SucceededSetAutostart, Message.FailedSetAutostart], execute: ({ isEnabled }) => - Effect.tryPromise(() => (isEnabled ? enableAutostart() : disableAutostart())).pipe( - Effect.as(isEnabled), - Effect.catch(() => readAutostart), - Effect.map((isEnabled) => Message.CompletedSetAutostart({ isEnabled })), + tauri(isEnabled ? enableAutostart : disableAutostart).pipe( + Effect.as(Message.SucceededSetAutostart({ isEnabled })), + Effect.catchTags({ + TauriPluginError: () => + readAutostart.pipe( + Effect.map((actual) => + Message.FailedSetAutostart({ reason: "PluginFailed", isEnabled: actual }), + ), + ), + }), ), }) -/** `useAppVersion()`: the installed version on desktop. */ +/** `useAppVersion()`: the installed version on desktop; unknown if the plugin fails. */ export const ReadVersion = Command.define("ReadVersion", { messages: [Message.ReadAppVersion], - execute: Effect.tryPromise(async () => (await getTauriApp()?.getVersion()) ?? null).pipe( - Effect.catch(() => Effect.succeed(null)), + execute: tauri(async () => (await getTauriApp()?.getVersion()) ?? null).pipe( + Effect.catchTags({ TauriPluginError: () => Effect.succeed(null) }), Effect.map((version) => Message.ReadAppVersion({ version })), ), }) @@ -51,12 +66,17 @@ export const init = (): PageReturn<Model, Message> => ({ commands: isDesktop ? [CheckAutostart(), ReadVersion()] : [CheckAutostart()], }) +const settleAutostart = (model: Model, isEnabled: boolean): Model => + modifyFields(model, { autostartEnabled: () => isEnabled, isUpdating: () => false }) + export const update = (model: Model, message: Message): PageReturn<Model, Message> => Message.match<PageReturn<Model, Message>>(message, { // The initial read only fills an unknown state, so it can never undo a later write. CheckedAutostart: ({ isEnabled }) => ({ model: - model.autostartEnabled === null ? modifyFields(model, { autostartEnabled: () => isEnabled }) : model, + model.autostartEnabled === null + ? modifyFields(model, { autostartEnabled: () => isEnabled }) + : model, }), ToggledAutostart: ({ isSelected }) => model.isUpdating || model.autostartEnabled === null @@ -65,8 +85,12 @@ export const update = (model: Model, message: Message): PageReturn<Model, Messag model: modifyFields(model, { isUpdating: () => true }), commands: [SetAutostart({ isEnabled: isSelected })], }, - CompletedSetAutostart: ({ isEnabled }) => ({ - model: modifyFields(model, { autostartEnabled: () => isEnabled, isUpdating: () => false }), + SucceededSetAutostart: ({ isEnabled }) => ({ model: settleAutostart(model, isEnabled) }), + FailedSetAutostart: ({ isEnabled }) => ({ + model: settleAutostart(model, isEnabled), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToast("Failed to update launch at login", "Please try again."), + }), }), GotInteractionMessage: ({ message: child }) => interaction.fold(model, child), ReadAppVersion: ({ version }) => ({ model: modifyFields(model, { version: () => version }) }), diff --git a/apps/web-foldkit/src/page/my-settings/desktop/view.ts b/apps/web-foldkit/src/page/my-settings/desktop/view.ts index 47d1d7c17..21044e470 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/view.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/view.ts @@ -2,7 +2,7 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { IconCheck, IconCube, IconDownload } from "../../../icons" import { isDesktop } from "../../../platform/desktop" -import type * as Updates from "../../../platform/updates" +import * as Updates from "../../../platform/updates" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" import { progressBar, progressBarTrack } from "../../../ui/progress-bar" @@ -17,7 +17,9 @@ const spinner = (h: HtmlBuilder<Message>, label: string): Html => h.div( [h.Class("flex items-center gap-2 text-sm text-muted-fg")], [ - h.span([h.Class("size-4 animate-spin rounded-full border-2 border-current border-t-transparent")]), + h.span([ + h.Class("size-4 animate-spin rounded-full border-2 border-current border-t-transparent"), + ]), h.span([], [label]), ], ) @@ -43,14 +45,12 @@ const checkRow = (h: HtmlBuilder<Message>, text: string, label: string): Html => const megabytes = (bytes: number) => (bytes / 1024 / 1024).toFixed(1) /** Legacy `UpdateStatusDisplay`: the update's state and what can be done next. */ -const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { - switch (state._tag) { - case "Idle": - return checkRow(h, "Check for available updates", "Check for updates") - case "Checking": - return spinner(h, "Checking for updates...") - case "Available": - return h.div( +const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => + Updates.Model.match(state, { + Idle: () => checkRow(h, "Check for available updates", "Check for updates"), + Checking: () => spinner(h, "Checking for updates..."), + Available: (state) => + h.div( [h.Class("flex flex-col gap-3")], [ h.div( @@ -60,7 +60,11 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { [h.Class("flex items-center gap-3")], [ h.div( - [h.Class("flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10")], + [ + h.Class( + "flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10", + ), + ], [IconDownload(h, { className: "size-4 text-primary" })], ), h.div( @@ -69,12 +73,26 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { h.div( [h.Class("flex items-center gap-2")], [ - h.span([h.Class("text-sm font-medium text-fg")], ["Update available"]), - badge(h, { intent: "primary", size: "sm" }, [`v${state.version}`]), + h.span( + [h.Class("text-sm font-medium text-fg")], + ["Update available"], + ), + badge(h, { intent: "primary", size: "sm" }, [ + `v${state.version}`, + ]), ], ), ...(state.body - ? [h.p([h.Class("mt-1 whitespace-pre-wrap text-sm text-muted-fg")], [state.body])] + ? [ + h.p( + [ + h.Class( + "mt-1 whitespace-pre-wrap text-sm text-muted-fg", + ), + ], + [state.body], + ), + ] : []), ], ), @@ -86,16 +104,20 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { "Install & Restart", ]), ], - ) - case "UpToDate": - return h.div( + ), + UpToDate: (state) => + h.div( [h.Class("flex items-center justify-between")], [ h.div( [h.Class("flex items-center gap-3")], [ h.div( - [h.Class("flex size-8 shrink-0 items-center justify-center rounded-full bg-success/10")], + [ + h.Class( + "flex size-8 shrink-0 items-center justify-center rounded-full bg-success/10", + ), + ], [IconCheck(h, { className: "size-4 text-success" })], ), h.div( @@ -114,10 +136,9 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { "Check again", ]), ], - ) - case "CheckFailed": - return retry(h, state.message, "Retry") - case "Downloading": { + ), + CheckFailed: (state) => retry(h, state.message, "Retry"), + Downloading: (state) => { const percent = state.totalBytes === null ? 0 : Math.round((state.downloadedBytes / state.totalBytes) * 100) return h.div( @@ -142,15 +163,11 @@ const updateStatus = (h: HtmlBuilder<Message>, state: Updates.Model): Html => { ]), ], ) - } - case "Installing": - return spinner(h, "Installing update...") - case "Restarting": - return spinner(h, "Restarting...") - case "InstallFailed": - return retry(h, state.message, "Retry") - } -} + }, + Installing: () => spinner(h, "Installing update..."), + Restarting: () => spinner(h, "Restarting..."), + InstallFailed: (state) => retry(h, state.message, "Retry"), + }) const appUpdates = (h: HtmlBuilder<Message>, model: Model, state: Updates.Model): ReadonlyArray<Html> => [ divider(h), @@ -173,16 +190,38 @@ const appUpdates = (h: HtmlBuilder<Message>, model: Model, state: Updates.Model) [h.Class("flex items-center gap-3")], [ h.div( - [h.Class("flex size-10 shrink-0 items-center justify-center rounded-lg bg-secondary")], + [ + h.Class( + "flex size-10 shrink-0 items-center justify-center rounded-lg bg-secondary", + ), + ], [IconCube(h, { className: "size-5 text-muted-fg" })], ), h.div( [h.Class("flex flex-1 flex-col gap-0.5")], [ - h.span([h.Class("text-sm text-muted-fg")], ["Current version"]), - badge(h, { intent: "secondary", size: "md", className: "self-start" }, [ - h.span([h.Class("font-mono")], [model.version ? `v${model.version}` : "..."]), - ]), + h.span( + [h.Class("text-sm text-muted-fg")], + ["Current version"], + ), + badge( + h, + { + intent: "secondary", + size: "md", + className: "self-start", + }, + [ + h.span( + [h.Class("font-mono")], + [ + model.version + ? `v${model.version}` + : "...", + ], + ), + ], + ), ], ), ], diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/command.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/command.ts index 0fb0a771e..c3bfcd25c 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/command.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/command.ts @@ -2,24 +2,28 @@ import { OrganizationId } from "@hazel/schema" import { Effect } from "effect" import { Command } from "foldkit" import { load } from "foldkit/navigation" +import { catchCommon } from "../../../data/actions" import { HazelApiClient } from "../../../rpc" import { Message } from "./message" -/** The legacy `HazelApiClient` (HTTP API, cookie or bearer auth through `authenticatedFetch`). */ -const integrations = HazelApiClient.useSync((client) => client.integrations) - /** `integrations.getOAuthUrl`, then a full-page redirect to the provider. */ export const StartDiscordLink = Command.define("StartDiscordLink", { args: { orgId: OrganizationId }, messages: [Message.SucceededGetDiscordOAuthUrl, Message.FailedGetDiscordOAuthUrl], execute: ({ orgId }) => - integrations.pipe( - Effect.flatMap((client) => - client.getOAuthUrl({ params: { orgId, provider: "discord" }, query: { level: "user" } }), - ), + HazelApiClient.use((client) => + client.integrations.getOAuthUrl({ + params: { orgId, provider: "discord" }, + query: { level: "user" }, + }), + ).pipe( Effect.flatMap((response) => load(response.authorizationUrl)), Effect.as(Message.SucceededGetDiscordOAuthUrl()), - Effect.catch(() => Effect.succeed(Message.FailedGetDiscordOAuthUrl())), + Effect.catchTags({ + UnsupportedProviderError: () => + Effect.succeed(Message.FailedGetDiscordOAuthUrl({ reason: "UnsupportedProvider" })), + }), + catchCommon((reason) => Message.FailedGetDiscordOAuthUrl({ reason })), ), }) @@ -27,11 +31,19 @@ export const DisconnectDiscord = Command.define("DisconnectDiscord", { args: { orgId: OrganizationId }, messages: [Message.SucceededDisconnectDiscord, Message.FailedDisconnectDiscord], execute: ({ orgId }) => - integrations.pipe( - Effect.flatMap((client) => - client.disconnect({ params: { orgId, provider: "discord" }, query: { level: "user" } }), - ), + HazelApiClient.use((client) => + client.integrations.disconnect({ + params: { orgId, provider: "discord" }, + query: { level: "user" }, + }), + ).pipe( Effect.as(Message.SucceededDisconnectDiscord()), - Effect.catch(() => Effect.succeed(Message.FailedDisconnectDiscord())), + Effect.catchTags({ + IntegrationNotConnectedError: () => + Effect.succeed(Message.FailedDisconnectDiscord({ reason: "NotConnected" })), + UnsupportedProviderError: () => + Effect.succeed(Message.FailedDisconnectDiscord({ reason: "UnsupportedProvider" })), + }), + catchCommon((reason) => Message.FailedDisconnectDiscord({ reason })), ), }) diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts index 37d138b32..adf6d8bc8 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/linked-accounts.test.ts @@ -27,17 +27,32 @@ describe("linked accounts callback", () => { expect(page.commands).toBeUndefined() }) - test("a failed link shows the error code", () => { + test("a failed link explains a known error code in words", () => { const page = init(routeWith({ status: "error", provider: "discord", errorCode: "db_error" })) expect(page.outMessage).toEqual( PageOutMessage.RequestedNavigation({ href: "/hazel/my-settings/linked-accounts", replace: true, - toast: { intent: "error", title: "Failed to link Discord account", description: "db_error" }, + toast: { + intent: "error", + title: "Failed to link Discord account", + description: "A database error occurred. Please try again.", + }, }), ) }) + test("an unknown error code never reaches the toast", () => { + const page = init(routeWith({ status: "error", provider: "discord", errorCode: "<script>" })) + expect(page.outMessage).toMatchObject({ + toast: { + intent: "error", + title: "Failed to link Discord account", + description: "Please try again.", + }, + }) + }) + test("other providers and the cleaned URL show nothing", () => { const page = init(routeWith({ status: "success", provider: "github" })) expect(page.outMessage).toBeUndefined() diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/message.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/message.ts index 83ae08352..0c6d7b813 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/message.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/message.ts @@ -1,16 +1,28 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" import { DiscordConnection } from "./model" +/** Why `integrations.getOAuthUrl` failed, beyond the shared reasons. */ +export const LinkFailure = Schema.Union([Schema.Literal("UnsupportedProvider"), CommonReason]) +export type LinkFailure = typeof LinkFailure.Type + +/** Why `integrations.disconnect` failed, beyond the shared reasons. */ +export const UnlinkFailure = Schema.Union([ + Schema.Literals(["NotConnected", "UnsupportedProvider"]), + CommonReason, +]) +export type UnlinkFailure = typeof UnlinkFailure.Type + export const Message = defineMessageUnion({ UpdatedDiscordConnection: { connection: Schema.NullOr(DiscordConnection) }, ClickedLinkDiscord: {}, SucceededGetDiscordOAuthUrl: {}, - FailedGetDiscordOAuthUrl: {}, + FailedGetDiscordOAuthUrl: { reason: LinkFailure }, ClickedUnlinkDiscord: {}, SucceededDisconnectDiscord: {}, - FailedDisconnectDiscord: {}, + FailedDisconnectDiscord: { reason: UnlinkFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/model.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/model.ts index 2027ca555..7815b3f32 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/model.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/model.ts @@ -1,18 +1,27 @@ +import { IntegrationConnection } from "@hazel/domain/models" import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" import * as Interaction from "../../../ui/aria/interaction" /** The user-level Discord connection row (`useUserIntegrationConnection`). */ export const DiscordConnection = Schema.Struct({ - status: Schema.String, + status: IntegrationConnection.ConnectionStatus, externalAccountName: Schema.NullOr(Schema.String), }) export type DiscordConnection = typeof DiscordConnection.Type +/** The Discord action in flight: redirecting to the OAuth page, or disconnecting. One at a time. */ +export const Link = defineTaggedUnion({ + Idle: {}, + Linking: {}, + Unlinking: {}, +}) +export type Link = typeof Link.Type + export const Model = Schema.Struct({ orgSlug: Schema.String, connection: Schema.NullOr(DiscordConnection), - isConnecting: Schema.Boolean, - isDisconnecting: Schema.Boolean, + link: Link, interaction: Interaction.Model, }) export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/scene.test.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/scene.test.ts index 5153ead8b..d917a2be9 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/scene.test.ts @@ -34,13 +34,13 @@ describe("discord card", () => { Scene.click(link), Scene.Command.expectExact(StartDiscordLink({ orgId: organizationId })), Scene.expect(Scene.role("button", { name: "Redirecting..." })).toBeDisabled(), - Scene.Command.resolve(StartDiscordLink, Message.FailedGetDiscordOAuthUrl()), + Scene.Command.resolve(StartDiscordLink, Message.FailedGetDiscordOAuthUrl({ reason: "Offline" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "error", title: "Failed to start Discord linking flow", - description: null, + description: "Check your internet connection and try again.", }, }), ), @@ -84,10 +84,17 @@ describe("discord card", () => { Scene.given(init(route).model), Scene.Subscription.emit(active), Scene.click(unlink), - Scene.Command.resolve(DisconnectDiscord, Message.FailedDisconnectDiscord()), + Scene.Command.resolve( + DisconnectDiscord, + Message.FailedDisconnectDiscord({ reason: "NotConnected" }), + ), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to unlink Discord account", description: null }, + toast: { + intent: "error", + title: "Failed to unlink Discord account", + description: "This Discord account is no longer linked.", + }, }), ), Scene.expect(unlink).toBeEnabled(), diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts index ab7472d1a..3639e5bb7 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/story.test.ts @@ -6,7 +6,7 @@ import { currentUser, makeShared, organizationId, storyUpdate } from "../../../t import { PageOutMessage } from "../../out-message" import { DisconnectDiscord, StartDiscordLink } from "./command" import { Message } from "./message" -import type { Model } from "./model" +import { Link, type Model } from "./model" import { init, update } from "./update" /** Update-loop tests for the Discord link and unlink actions and the callback URL cleanup. */ @@ -20,9 +20,12 @@ const route = { errorCode: Option.none(), } const initial = () => init(route).model -const linked = () => ({ ...initial(), connection: { status: "active", externalAccountName: "ada#1815" } }) -const toast = (intent: "success" | "error", title: string) => - PageOutMessage.RequestedToast({ toast: { intent, title, description: null } }) +const linked = () => ({ + ...initial(), + connection: { status: "active" as const, externalAccountName: "ada#1815" }, +}) +const toast = (intent: "success" | "error", title: string, description: string | null = null) => + PageOutMessage.RequestedToast({ toast: { intent, title, description } }) describe("link discord", () => { test("linking starts the OAuth redirect for the user's organization", () => { @@ -31,10 +34,10 @@ describe("link discord", () => { given(initial()), message(Message.ClickedLinkDiscord()), Command.expectExact(StartDiscordLink({ orgId: organizationId })), - model((current) => expect(current.isConnecting).toBe(true)), + model((current) => expect(current.link).toEqual(Link.Linking())), Command.resolve(StartDiscordLink, Message.SucceededGetDiscordOAuthUrl()), expectNoOutMessage(), - model((current) => expect(current.isConnecting).toBe(true)), + model((current) => expect(current.link).toEqual(Link.Linking())), ) }) @@ -43,9 +46,18 @@ describe("link discord", () => { storyUpdate(update, shared), given(initial()), message(Message.ClickedLinkDiscord()), - Command.resolve(StartDiscordLink, Message.FailedGetDiscordOAuthUrl()), - expectOutMessage(toast("error", "Failed to start Discord linking flow")), - model((current) => expect(current.isConnecting).toBe(false)), + Command.resolve( + StartDiscordLink, + Message.FailedGetDiscordOAuthUrl({ reason: "UnsupportedProvider" }), + ), + expectOutMessage( + toast( + "error", + "Failed to start Discord linking flow", + "Discord linking is not supported right now.", + ), + ), + model((current) => expect(current.link).toEqual(Link.Idle())), ) }) @@ -63,7 +75,7 @@ describe("link discord", () => { test("a second link click while redirecting is ignored", () => { story<Model, Message, PageOutMessage>( storyUpdate(update, shared), - given({ ...initial(), isConnecting: true }), + given({ ...initial(), link: Link.Linking() }), message(Message.ClickedLinkDiscord()), Command.expectNone(), ) @@ -77,10 +89,10 @@ describe("unlink discord", () => { given(linked()), message(Message.ClickedUnlinkDiscord()), Command.expectExact(DisconnectDiscord({ orgId: organizationId })), - model((current) => expect(current.isDisconnecting).toBe(true)), + model((current) => expect(current.link).toEqual(Link.Unlinking())), Command.resolve(DisconnectDiscord, Message.SucceededDisconnectDiscord()), expectOutMessage(toast("success", "Discord account unlinked")), - model((current) => expect(current.isDisconnecting).toBe(false)), + model((current) => expect(current.link).toEqual(Link.Idle())), ) }) @@ -89,10 +101,16 @@ describe("unlink discord", () => { storyUpdate(update, shared), given(linked()), message(Message.ClickedUnlinkDiscord()), - Command.resolve(DisconnectDiscord, Message.FailedDisconnectDiscord()), - expectOutMessage(toast("error", "Failed to unlink Discord account")), + Command.resolve(DisconnectDiscord, Message.FailedDisconnectDiscord({ reason: "Timeout" })), + expectOutMessage( + toast( + "error", + "Failed to unlink Discord account", + "The server is taking too long to respond. Please try again.", + ), + ), model((current) => { - expect(current.isDisconnecting).toBe(false) + expect(current.link).toEqual(Link.Idle()) expect(current.connection?.status).toBe("active") }), ) @@ -102,7 +120,7 @@ describe("unlink discord", () => { test("a second unlink click while one is pending is ignored", () => { story<Model, Message, PageOutMessage>( storyUpdate(update, shared), - given({ ...linked(), isDisconnecting: true }), + given({ ...linked(), link: Link.Unlinking() }), message(Message.ClickedUnlinkDiscord()), Command.expectNone(), ) @@ -133,7 +151,11 @@ describe("link callback", () => { provider: Option.some("discord"), }) expect(callback.outMessage).toMatchObject({ - toast: { intent: "error", title: "Failed to link Discord account", description: "Please try again." }, + toast: { + intent: "error", + title: "Failed to link Discord account", + description: "Please try again.", + }, }) }) }) diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/subscription.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/subscription.ts index e6f874353..0f93e3191 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/subscription.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/subscription.ts @@ -1,3 +1,4 @@ +import type { IntegrationConnection } from "@hazel/domain/models" import { OrganizationId, UserId } from "@hazel/schema" import { and, eq, isNull } from "@tanstack/db" import { Schema, Stream } from "effect" @@ -10,7 +11,7 @@ import type { DiscordConnection, Model } from "./model" import { interaction } from "./update" interface ConnectionRow { - readonly status: string + readonly status: IntegrationConnection.ConnectionStatus readonly externalAccountName?: string | null } diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts index 469300cb1..cd40b7954 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/update.ts @@ -1,26 +1,39 @@ import { Option } from "effect" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import { errorToast, successToast } from "../../../data/actions" -import type { ToastRequest } from "../../../overlay/toasts" import * as Interaction from "../../../ui/aria/interaction" -import type { PageReturn, Shared } from "../../contract" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { embedInteraction } from "../shared" import { DisconnectDiscord, StartDiscordLink } from "./command" import { Message } from "./message" -import type { Model } from "./model" +import { Link, type Model } from "./model" import { linkedAccountsHref, type RouteOf } from "../../../route" type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) -const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) - type Route = RouteOf<"MySettingsLinkedAccounts"> +/** The backend's OAuth callback `error_code`s (`OAuthErrorCode`), in words; anything else is a retry hint. */ +const callbackErrorCopy: Readonly<Record<string, string>> = { + token_exchange_failed: "Could not authenticate with Discord. Please try again.", + account_info_failed: "Could not fetch your Discord account information.", + db_error: "A database error occurred. Please try again.", + encryption_error: "A security error occurred. Please try again.", + invalid_state: "The link request expired. Please try again.", +} + +const callbackErrorOf = (errorCode: Option.Option<string>): string => + Option.match(errorCode, { + onNone: () => "Please try again.", + onSome: (code) => callbackErrorCopy[code] ?? "Please try again.", + }) + /** The OAuth callback's `?connection_status=&provider=&error_code=`: clean the URL with the result toast. */ const linkResult = (model: Model, route: Route): Return => { const status = Option.getOrNull(route.connectionStatus) @@ -33,10 +46,7 @@ const linkResult = (model: Model, route: Route): Return => { toast: status === "success" ? successToast("Discord account linked") - : errorToast( - "Failed to link Discord account", - Option.getOrNull(route.errorCode) ?? "Please try again.", - ), + : errorToast("Failed to link Discord account", callbackErrorOf(route.errorCode)), }), } } @@ -46,8 +56,7 @@ export const init = (route: Route): Return => { orgSlug: route.orgSlug, connection: null, - isConnecting: false, - isDisconnecting: false, + link: Link.Idle(), interaction: Interaction.init(), }, route, @@ -56,38 +65,62 @@ export const init = (route: Route): Return => /** Same page, new search: a later callback redirect, or the cleaned URL (nothing to show). */ export const routeChanged = (model: Model, route: Route): Return => linkResult(model, route) +const unsupported = { + title: "Discord is not available", + description: "Discord linking is not supported right now.", +} + export const update = (model: Model, message: Message, shared: Shared): Return => { const orgId = shared.currentUser?.organizationId ?? null + const isIdle = model.link._tag === "Idle" + const toIdle = () => modifyFields(model, { link: () => Link.Idle() }) return Message.match<Return>(message, { UpdatedDiscordConnection: ({ connection }) => ({ model: modifyFields(model, { connection: () => connection }), }), ClickedLinkDiscord: () => - orgId === null || model.isConnecting + orgId === null || !isIdle ? { model } : { - model: modifyFields(model, { isConnecting: () => true }), + model: modifyFields(model, { link: () => Link.Linking() }), commands: [StartDiscordLink({ orgId })], }, + // The page is navigating away; `Linking` keeps the button pending until it does. SucceededGetDiscordOAuthUrl: () => ({ model }), - FailedGetDiscordOAuthUrl: () => ({ - model: modifyFields(model, { isConnecting: () => false }), - outMessage: toast(errorToast("Failed to start Discord linking flow")), + FailedGetDiscordOAuthUrl: ({ reason }) => ({ + model: toIdle(), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToast( + "Failed to start Discord linking flow", + failureMessage(reason, { UnsupportedProvider: unsupported }).description ?? null, + ), + }), }), ClickedUnlinkDiscord: () => - orgId === null || model.isDisconnecting + orgId === null || !isIdle ? { model } : { - model: modifyFields(model, { isDisconnecting: () => true }), + model: modifyFields(model, { link: () => Link.Unlinking() }), commands: [DisconnectDiscord({ orgId })], }, SucceededDisconnectDiscord: () => ({ - model: modifyFields(model, { isDisconnecting: () => false }), - outMessage: toast(successToast("Discord account unlinked")), + model: toIdle(), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Discord account unlinked") }), }), - FailedDisconnectDiscord: () => ({ - model: modifyFields(model, { isDisconnecting: () => false }), - outMessage: toast(errorToast("Failed to unlink Discord account")), + FailedDisconnectDiscord: ({ reason }) => ({ + model: toIdle(), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToast( + "Failed to unlink Discord account", + failureMessage(reason, { + NotConnected: { + title: "Not linked", + description: "This Discord account is no longer linked.", + }, + UnsupportedProvider: unsupported, + }).description ?? null, + ), + }), }), GotInteractionMessage: ({ message: child }) => interaction.fold(model, child), }) diff --git a/apps/web-foldkit/src/page/my-settings/linked-accounts/view.ts b/apps/web-foldkit/src/page/my-settings/linked-accounts/view.ts index 72d9388a6..d5df84182 100644 --- a/apps/web-foldkit/src/page/my-settings/linked-accounts/view.ts +++ b/apps/web-foldkit/src/page/my-settings/linked-accounts/view.ts @@ -10,28 +10,30 @@ import { interaction } from "./update" export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, _inputs, h) => { const isConnected = model.connection?.status === "active" const wiring = interaction.wiring(model) + const isLinking = model.link._tag === "Linking" + const isUnlinking = model.link._tag === "Unlinking" const action = isConnected ? button( h, { intent: "danger", size: "sm", - isDisabled: model.isDisconnecting, + isDisabled: isUnlinking, onPress: Message.ClickedUnlinkDiscord(), interaction: { wiring, target: "unlink-discord" }, }, - [model.isDisconnecting ? "Unlinking..." : "Unlink"], + [isUnlinking ? "Unlinking..." : "Unlink"], ) : button( h, { intent: "primary", size: "sm", - isDisabled: model.isConnecting, + isDisabled: isLinking, onPress: Message.ClickedLinkDiscord(), interaction: { wiring, target: "link-discord" }, }, - [model.isConnecting ? "Redirecting..." : "Link Discord"], + [isLinking ? "Redirecting..." : "Link Discord"], ) return h.div( [h.Class("flex flex-col gap-6 px-4 lg:px-8")], diff --git a/apps/web-foldkit/src/page/my-settings/notifications/command.ts b/apps/web-foldkit/src/page/my-settings/notifications/command.ts index 088254413..483f60469 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/command.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/command.ts @@ -1,24 +1,41 @@ import { User } from "@hazel/domain/models" import { UserId } from "@hazel/schema" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { testNativeNotification } from "~/lib/native-notifications" import { notificationSoundManager } from "~/lib/notification-sound-manager" -import { updateUser } from "../user" +import { settleUpdateUser, updateUser } from "../user" import { Message } from "./message" +/** A sound or notification preview threw (audio playback, the notification plugin). */ +class TestPreviewError extends Schema.TaggedError<TestPreviewError>()("TestPreviewError", { + cause: Schema.Defect(), +}) {} + +const preview = <A>(run: () => Promise<A>) => + Effect.tryPromise({ try: run, catch: (cause) => new TestPreviewError({ cause }) }) + export const PlayTestSound = Command.define("PlayTestSound", { messages: [Message.CompletedTestSound], - execute: Effect.promise(() => notificationSoundManager.testSound()).pipe( + execute: preview(() => notificationSoundManager.testSound()).pipe( + // Best-effort preview: `testSound` reports its own outcomes, and a throw has nothing to show. + Effect.ignore, Effect.as(Message.CompletedTestSound()), ), }) export const SendTestNotification = Command.define("SendTestNotification", { - messages: [Message.CompletedTestNotification], - execute: Effect.tryPromise(() => testNativeNotification()).pipe( - Effect.catch(() => Effect.succeed(false)), - Effect.map((isSent) => Message.CompletedTestNotification({ isSent })), + messages: [Message.SucceededSendTestNotification, Message.FailedSendTestNotification], + execute: preview(testNativeNotification).pipe( + Effect.map((isSent) => + isSent + ? Message.SucceededSendTestNotification() + : Message.FailedSendTestNotification({ reason: "Unavailable" }), + ), + Effect.catchTags({ + TestPreviewError: () => + Effect.succeed(Message.FailedSendTestNotification({ reason: "SendFailed" })), + }), ), }) @@ -34,8 +51,9 @@ export const UpdateUserSettings = Command.define("UpdateUserSettings", { args: { userId: UserId, settings: User.UserSettingsSchema }, messages: [Message.SucceededUpdateUserSettings, Message.FailedUpdateUserSettings], execute: ({ userId, settings }) => - updateUser({ id: userId, settings }).pipe( - Effect.as(Message.SucceededUpdateUserSettings()), - Effect.catch(() => Effect.succeed(Message.FailedUpdateUserSettings())), + settleUpdateUser( + updateUser({ id: userId, settings }), + Message.SucceededUpdateUserSettings(), + (reason) => Message.FailedUpdateUserSettings({ reason }), ), }) diff --git a/apps/web-foldkit/src/page/my-settings/notifications/message.ts b/apps/web-foldkit/src/page/my-settings/notifications/message.ts index 3c46a5e50..9dcd661e0 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/message.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/message.ts @@ -3,9 +3,13 @@ import { defineMessageUnion } from "foldkit/message" import * as Interaction from "../../../ui/aria/interaction" import * as Segments from "../../../ui/date-segments" import * as Slider from "../../../ui/slider" -import { UserRow } from "../user" +import { UpdateUserFailure, UserRow } from "../user" import { QuietHoursField, SoundFile } from "./model" +/** Why the test notification did not show: no native API or permission, or the plugin threw. */ +export const TestNotificationFailure = Schema.Literals(["Unavailable", "SendFailed"]) +export type TestNotificationFailure = typeof TestNotificationFailure.Type + export const Message = defineMessageUnion({ ToggledSounds: { isSelected: Schema.Boolean }, SelectedSound: { soundFile: SoundFile }, @@ -13,14 +17,15 @@ export const Message = defineMessageUnion({ ClickedTestSound: {}, CompletedTestSound: {}, ClickedTestNotification: {}, - CompletedTestNotification: { isSent: Schema.Boolean }, + SucceededSendTestNotification: {}, + FailedSendTestNotification: { reason: TestNotificationFailure }, ExpiredNotificationStatus: { version: Schema.Number }, UpdatedUserRow: { row: Schema.NullOr(UserRow) }, ToggledDoNotDisturb: { isSelected: Schema.Boolean }, ToggledShowQuietHours: { isSelected: Schema.Boolean }, GotQuietHoursMessage: { fieldId: QuietHoursField, message: Segments.Message }, SucceededUpdateUserSettings: {}, - FailedUpdateUserSettings: {}, + FailedUpdateUserSettings: { reason: UpdateUserFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/my-settings/notifications/model.ts b/apps/web-foldkit/src/page/my-settings/notifications/model.ts index 2b3c2ab77..f40e051eb 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/model.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/model.ts @@ -5,11 +5,7 @@ import * as Segments from "../../../ui/date-segments" import * as Slider from "../../../ui/slider" /** The sound settings are `Shared.soundSettings` (root-owned, like the legacy provider's atom). */ -export { - DEFAULT_SOUND_SETTINGS, - SoundFile, - SoundSettings, -} from "../../../notification-sound" +export { DEFAULT_SOUND_SETTINGS, SoundFile, SoundSettings } from "../../../notification-sound" export const NotificationStatus = Schema.Literals(["idle", "sent", "unavailable"]) export type NotificationStatus = typeof NotificationStatus.Type diff --git a/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts b/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts index 205b1559b..8e21d4332 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/scene.test.ts @@ -54,10 +54,17 @@ describe("do not disturb", () => { Scene.expect(showQuietHours).toBeChecked(), Scene.click(showQuietHours), Scene.expect(showQuietHours).not.toBeChecked(), - Scene.Command.resolve(UpdateUserSettings, Message.FailedUpdateUserSettings()), + Scene.Command.resolve( + UpdateUserSettings, + Message.FailedUpdateUserSettings({ reason: "Offline" }), + ), Scene.expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to update setting", description: null }, + toast: { + intent: "error", + title: "Failed to update setting", + description: "Check your internet connection and try again.", + }, }), ), Scene.expect(showQuietHours).toBeChecked(), @@ -96,9 +103,15 @@ describe("sounds", () => { Scene.given(init(undefined, shared).model), Scene.click(Scene.role("button", { name: "Test notification" })), Scene.Command.expectExact(SendTestNotification), - Scene.Command.resolve(SendTestNotification, Message.CompletedTestNotification({ isSent: false })), + Scene.Command.resolve( + SendTestNotification, + Message.FailedSendTestNotification({ reason: "Unavailable" }), + ), Scene.expect(note).toExist(), - Scene.Command.resolve(ExpireNotificationStatus, Message.ExpiredNotificationStatus({ version: 1 })), + Scene.Command.resolve( + ExpireNotificationStatus, + Message.ExpiredNotificationStatus({ version: 1 }), + ), Scene.expect(note).toBeAbsent(), ) }) diff --git a/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts b/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts index edc89c61a..19e0c1a5e 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/story.test.ts @@ -69,10 +69,14 @@ describe("notification preferences", () => { doNotDisturb: true, }), ), - Command.resolve(UpdateUserSettings, Message.FailedUpdateUserSettings()), + Command.resolve(UpdateUserSettings, Message.FailedUpdateUserSettings({ reason: "UserNotFound" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to update setting", description: null }, + toast: { + intent: "error", + title: "Failed to update setting", + description: "Please sign in again.", + }, }), ), model((current) => expect(settingsOf(current)?.doNotDisturb).toBeUndefined()), @@ -129,8 +133,12 @@ describe("user settings writes", () => { }) const synced = () => - pageUpdate(init(undefined, shared).model, Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) })).model - const settingsWith = (patch: object) => Schema.decodeUnknownSync(User.UserSettingsSchema)({ ...row.settings, ...patch }) + pageUpdate( + init(undefined, shared).model, + Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)(row) }), + ).model + const settingsWith = (patch: object) => + Schema.decodeUnknownSync(User.UserSettingsSchema)({ ...row.settings, ...patch }) const writeOf = (result: ReturnType<typeof pageUpdate>) => result.commands?.map((command) => ({ name: command.name, args: command.args })) @@ -138,7 +146,10 @@ describe("user settings writes", () => { const both = settingsWith({ showQuietHoursInStatus: true, doNotDisturb: true }) const first = pageUpdate(synced(), Message.ToggledDoNotDisturb({ isSelected: true })) expect(writeOf(first)).toEqual([ - { name: UpdateUserSettings.name, args: { userId: ada, settings: settingsWith({ doNotDisturb: true }) } }, + { + name: UpdateUserSettings.name, + args: { userId: ada, settings: settingsWith({ doNotDisturb: true }) }, + }, ]) const queued = pageUpdate(first.model, Message.ToggledShowQuietHours({ isSelected: true })) expect(queued.commands ?? []).toEqual([]) @@ -146,11 +157,18 @@ describe("user settings writes", () => { // The first write's echo arrives while the second change is still unsent. const echoed = pageUpdate( queued.model, - Message.UpdatedUserRow({ row: Schema.decodeUnknownSync(UserRow)({ ...row, settings: settingsWith({ doNotDisturb: true }) }) }), + Message.UpdatedUserRow({ + row: Schema.decodeUnknownSync(UserRow)({ + ...row, + settings: settingsWith({ doNotDisturb: true }), + }), + }), ) expect(settingsOf(echoed.model)).toEqual(both) const second = pageUpdate(echoed.model, Message.SucceededUpdateUserSettings()) - expect(writeOf(second)).toEqual([{ name: UpdateUserSettings.name, args: { userId: ada, settings: both } }]) + expect(writeOf(second)).toEqual([ + { name: UpdateUserSettings.name, args: { userId: ada, settings: both } }, + ]) const done = pageUpdate(second.model, Message.SucceededUpdateUserSettings()).model expect(done.settingsWrite).toBe("Idle") expect(done.optimisticSettings).toBeNull() @@ -160,15 +178,26 @@ describe("user settings writes", () => { test("an older write failing still sends the newer queued snapshot", () => { const first = pageUpdate(synced(), Message.ToggledDoNotDisturb({ isSelected: true })).model const queued = pageUpdate(first, Message.ToggledDoNotDisturb({ isSelected: false })).model - const failed = pageUpdate(queued, Message.FailedUpdateUserSettings()) + const failed = pageUpdate(queued, Message.FailedUpdateUserSettings({ reason: "Unexpected" })) expect(failed.outMessage).toEqual( - PageOutMessage.RequestedToast({ toast: { intent: "error", title: "Failed to update setting", description: null } }), + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Failed to update setting", + description: "Please try again.", + }, + }), ) expect(writeOf(failed)).toEqual([ - { name: UpdateUserSettings.name, args: { userId: ada, settings: settingsWith({ doNotDisturb: false }) } }, + { + name: UpdateUserSettings.name, + args: { userId: ada, settings: settingsWith({ doNotDisturb: false }) }, + }, ]) expect(settingsOf(failed.model)?.doNotDisturb).toBe(false) - expect(pageUpdate(failed.model, Message.SucceededUpdateUserSettings()).model.settingsWrite).toBe("Idle") + expect(pageUpdate(failed.model, Message.SucceededUpdateUserSettings()).model.settingsWrite).toBe( + "Idle", + ) }) test("without a signed-in user nothing is written", () => { @@ -190,7 +219,7 @@ describe("test notification", () => { given(init(undefined, shared).model), message(Message.ClickedTestNotification()), Command.expectExact(SendTestNotification()), - Command.resolve(SendTestNotification, Message.CompletedTestNotification({ isSent: true })), + Command.resolve(SendTestNotification, Message.SucceededSendTestNotification()), model((current) => expect(current.notificationStatus).toBe("sent")), Command.expectExact(ExpireNotificationStatus({ version: 1 })), Command.resolve(ExpireNotificationStatus, Message.ExpiredNotificationStatus({ version: 1 })), @@ -199,10 +228,13 @@ describe("test notification", () => { }) test("an older expiry timer leaves a newer result showing", () => { - const first = pageUpdate(init(undefined, shared).model, Message.CompletedTestNotification({ isSent: true })).model - const second = pageUpdate(first, Message.CompletedTestNotification({ isSent: false })).model + const first = pageUpdate(init(undefined, shared).model, Message.SucceededSendTestNotification()).model + const second = pageUpdate(first, Message.FailedSendTestNotification({ reason: "SendFailed" })).model const expiredFirst = pageUpdate(second, Message.ExpiredNotificationStatus({ version: 1 })).model expect(expiredFirst.notificationStatus).toBe("unavailable") - expect(pageUpdate(expiredFirst, Message.ExpiredNotificationStatus({ version: 2 })).model.notificationStatus).toBe("idle") + expect( + pageUpdate(expiredFirst, Message.ExpiredNotificationStatus({ version: 2 })).model + .notificationStatus, + ).toBe("idle") }) }) diff --git a/apps/web-foldkit/src/page/my-settings/notifications/update.ts b/apps/web-foldkit/src/page/my-settings/notifications/update.ts index cc0a8ad9f..3e1158450 100644 --- a/apps/web-foldkit/src/page/my-settings/notifications/update.ts +++ b/apps/web-foldkit/src/page/my-settings/notifications/update.ts @@ -2,22 +2,19 @@ import { User } from "@hazel/domain/models" import { Option, Schema } from "effect" import { Update } from "foldkit" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" import { errorToast } from "../../../data/actions" import * as Interaction from "../../../ui/aria/interaction" import * as Segments from "../../../ui/date-segments" import * as Slider from "../../../ui/slider" -import type { PageReturn, Shared } from "../../contract" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { embedInteraction } from "../shared" -import { - ExpireNotificationStatus, - PlayTestSound, - SendTestNotification, - UpdateUserSettings, -} from "./command" +import { ExpireNotificationStatus, PlayTestSound, SendTestNotification, UpdateUserSettings } from "./command" import { Message } from "./message" +import { updateUserFailureCopy } from "../user" import { type Model, + type NotificationStatus, type QuietHoursField, settingsOf, type SoundSettings, @@ -25,8 +22,9 @@ import { type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) export const toVolumeMessage = (message: Slider.Message) => Message.GotVolumeMessage({ message }) @@ -114,6 +112,18 @@ const settleWrite = ( } } +/** Shows the test notification's outcome; `ExpireNotificationStatus` clears it after 3 seconds. */ +const showNotificationStatus = (model: Model, status: NotificationStatus): Return => { + const version = model.notificationStatusVersion + 1 + return { + model: modifyFields(model, { + notificationStatus: () => status, + notificationStatusVersion: () => version, + }), + commands: [ExpireNotificationStatus({ version })], + } +} + const decodeTimeString = Schema.decodeUnknownOption(User.TimeString) const foldQuietHours = (field: QuietHoursField) => @@ -137,21 +147,25 @@ const foldVolume = Update.foldChild({ export const init = (_route: unknown, shared: Shared): Return => ({ model: syncVolume( { - settings: null, - optimisticSettings: null, - settingsWrite: "Idle", - volume: Slider.init({ - id: "notification-volume", - values: [0.5], - minValue: 0, - maxValue: 1, - step: 0.1, - }), - quietHoursStart: Segments.init({ id: "quiet-hours-start", kind: "time", value: DEFAULT_QUIET_START }), - quietHoursEnd: Segments.init({ id: "quiet-hours-end", kind: "time", value: DEFAULT_QUIET_END }), - notificationStatus: "idle", - notificationStatusVersion: 0, - interaction: Interaction.init(), + settings: null, + optimisticSettings: null, + settingsWrite: "Idle", + volume: Slider.init({ + id: "notification-volume", + values: [0.5], + minValue: 0, + maxValue: 1, + step: 0.1, + }), + quietHoursStart: Segments.init({ + id: "quiet-hours-start", + kind: "time", + value: DEFAULT_QUIET_START, + }), + quietHoursEnd: Segments.init({ id: "quiet-hours-end", kind: "time", value: DEFAULT_QUIET_END }), + notificationStatus: "idle", + notificationStatusVersion: 0, + interaction: Interaction.init(), }, shared.soundSettings, ), @@ -174,16 +188,9 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedTestSound: () => ({ model, commands: [PlayTestSound()] }), CompletedTestSound: () => ({ model }), ClickedTestNotification: () => ({ model, commands: [SendTestNotification()] }), - CompletedTestNotification: ({ isSent }) => { - const version = model.notificationStatusVersion + 1 - return { - model: modifyFields(model, { - notificationStatus: () => (isSent ? "sent" : "unavailable"), - notificationStatusVersion: () => version, - }), - commands: [ExpireNotificationStatus({ version })], - } - }, + SucceededSendTestNotification: () => showNotificationStatus(model, "sent"), + // Either way the note says native notifications are unavailable here. + FailedSendTestNotification: () => showNotificationStatus(model, "unavailable"), ExpiredNotificationStatus: ({ version }) => ({ model: version === model.notificationStatusVersion @@ -207,7 +214,9 @@ export const update = (model: Model, message: Message, shared: Shared): Return = const result = foldQuietHours(field)(model, child) const before = field === "start" ? model.quietHoursStart : model.quietHoursEnd const after = field === "start" ? result.model.quietHoursStart : result.model.quietHoursEnd - const time = Option.fromNullishOr(after.committed?.slice(0, 5)).pipe(Option.flatMap(decodeTimeString)) + const time = Option.fromNullishOr(after.committed?.slice(0, 5)).pipe( + Option.flatMap(decodeTimeString), + ) if (Option.isNone(time) || after.committed === before.committed) return result const patch = field === "start" ? { quietHoursStart: time.value } : { quietHoursEnd: time.value } const written = withUserSettings(result.model, shared, patch) @@ -219,9 +228,14 @@ export const update = (model: Model, message: Message, shared: Shared): Return = // The server now holds the written snapshot, so it stands until the synced row echoes it. SucceededUpdateUserSettings: () => settleWrite(model, shared, (current) => current.optimisticSettings ?? current.settings), - FailedUpdateUserSettings: () => ({ + FailedUpdateUserSettings: ({ reason }) => ({ ...settleWrite(model, shared, (current) => current.settings), - outMessage: PageOutMessage.RequestedToast({ toast: errorToast("Failed to update setting") }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToast( + "Failed to update setting", + failureMessage(reason, updateUserFailureCopy).description ?? null, + ), + }), }), GotInteractionMessage: ({ message: child }) => interaction.fold(model, child), }) diff --git a/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts b/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts index ea9fa724a..6a776a99f 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/avatar-view.ts @@ -9,13 +9,14 @@ import { loader } from "../../../ui/loader" import * as Modal from "../../../ui/modal" import { cropArea } from "./crop" import { Message } from "./message" +import * as DropZone from "../../shared/drop-zone" +import { ALLOWED_AVATAR_TYPES } from "../../settings/upload" import { AVATAR_BUTTON, FILE_INPUT_ID, type Model } from "./model" -import { interaction, toCropModalMessage } from "./update" +import { interaction, toCropModalMessage, toDropZoneMessage } from "./update" /** Port of `components/profile/profile-picture-upload.tsx` and `avatar-crop-modal.tsx`. */ const squircle = <M>(h: HtmlBuilder<M>) => h.Attribute("style", "corner-shape: squircle;") -const ALLOWED_TYPES = "image/jpeg,image/png,image/webp" const dropOverlay = <M>(h: HtmlBuilder<M>, label: string, labelClassName: string) => h.div( @@ -67,7 +68,9 @@ export const profilePictureUpload = ( user: { readonly avatarUrl: string | null; readonly initials: string }, ): Html => { const wiring = interaction.wiring(model) - const isDraggingOnPage = model.dragDepth > 0 + const isUploading = model.avatarAction._tag === "Uploading" + const isOver = model.dropZone.isOver + const isDraggingOnPage = DropZone.isDraggingOnPage(model.dropZone) return h.div( [h.Class("relative inline-block")], [ @@ -77,15 +80,11 @@ export const profilePictureUpload = ( "rounded-xl focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2", ), h.DataAttribute("rac", ""), - ...(model.isDropTarget ? [h.DataAttribute("drop-target", "true")] : []), - ...(model.isUploading + ...(isUploading ? [h.DataAttribute("disabled", "true")] - : [ - h.OnDragEnter(Message.EnteredDropZone()), - h.OnDragLeave(Message.LeftDropZone()), - h.OnDragOver(Message.EnteredDropZone()), - h.OnDropFiles((files) => Message.SelectedAvatarFiles({ files })), - ]), + : DropZone.zoneAttributes(h, model.dropZone, toDropZoneMessage, (files) => + Message.SelectedAvatarFiles({ files }), + )), ], [ h.div( @@ -110,15 +109,15 @@ export const profilePictureUpload = ( { className: cx( "group relative size-24 cursor-pointer rounded-xl transition-all duration-200", - model.isUploading && "pointer-events-none", - model.isDropTarget && "scale-105", + isUploading && "pointer-events-none", + isOver && "scale-105", ), onPress: Message.ClickedAvatar(), interaction: { wiring, target: AVATAR_BUTTON }, attributes: [ squircle(h), h.AriaLabel("Change profile picture"), - ...(model.isUploading ? [h.Attribute("aria-busy", "true")] : []), + ...(isUploading ? [h.Attribute("aria-busy", "true")] : []), ], }, [ @@ -129,7 +128,7 @@ export const profilePictureUpload = ( size: "4xl", className: "transition-all duration-200", }), - ...(model.isDropTarget + ...(isOver ? [ dropOverlay( h, @@ -138,13 +137,11 @@ export const profilePictureUpload = ( ), ] : []), - ...(isDraggingOnPage && !model.isDropTarget + ...(isDraggingOnPage && !isOver ? [dropOverlay(h, "Drop image", "font-medium text-fg text-xs")] : []), - ...(!model.isDropTarget && !isDraggingOnPage - ? [editOverlay(h, model.isUploading)] - : []), - ...(model.isUploading + ...(!isOver && !isDraggingOnPage ? [editOverlay(h, isUploading)] : []), + ...(isUploading ? [ h.div( [ @@ -168,7 +165,7 @@ export const profilePictureUpload = ( h.input([ h.Id(FILE_INPUT_ID), h.Type("file"), - h.Attribute("accept", ALLOWED_TYPES), + h.Attribute("accept", ALLOWED_AVATAR_TYPES.join(",")), h.Class(""), h.DataAttribute("rac", ""), h.Style({ display: "none" }), @@ -181,12 +178,12 @@ export const profilePictureUpload = ( [ h.Type("button"), h.OnClick(Message.ClickedResetAvatar()), - h.Disabled(model.isResetting || model.isUploading), + h.Disabled(model.avatarAction._tag !== "Idle"), h.Class( "mt-1 w-full text-center text-muted-fg text-xs underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50", ), ], - [model.isResetting ? "Removing..." : "Remove photo"], + [model.avatarAction._tag === "Resetting" ? "Removing..." : "Remove photo"], ), Modal.controlledModal(h, { model: model.cropModal, @@ -236,9 +233,14 @@ const cropContent = ( ["Cancel"], ), ]), - button(h, { onPress: Message.ClickedSaveCrop(), isDisabled: !isReady || isProcessing }, [ - ...(isProcessing ? [loader(h, {}), "Saving..."] : ["Save"]), - ]), + button( + h, + { + onPress: Message.ClickedSaveCrop(), + isDisabled: !isReady || isProcessing || model.avatarAction._tag !== "Idle", + }, + isProcessing ? [loader(h, {}), "Saving..."] : ["Save"], + ), ]), ] } diff --git a/apps/web-foldkit/src/page/my-settings/profile/command.ts b/apps/web-foldkit/src/page/my-settings/profile/command.ts index f31b07173..c7c2a8995 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/command.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/command.ts @@ -2,10 +2,11 @@ import { UserId } from "@hazel/schema" import { Effect, Schema } from "effect" import { Command } from "foldkit" import { File } from "foldkit/file" +import { catchCommon } from "../../../data/actions" import { calculateInitialCrop, cropImage, loadImage } from "~/utils/image-crop" import { detectBrowserTimezone } from "~/utils/timezone" -import { publicUrlOf, uploadFile } from "../../settings/upload" -import { updateUser } from "../user" +import { UploadTarget, uploadFile } from "../../settings/upload" +import { settleUpdateUser, updateUser } from "../user" import { CropRect } from "./crop" import { Message } from "./message" import { FILE_INPUT_ID, FormValues } from "./model" @@ -46,12 +47,13 @@ export const LoadCropImage = Command.define("LoadCropImage", { }, }), ), - Effect.catch(() => - Effect.sync(() => { - URL.revokeObjectURL(src) - return Message.FailedLoadCropImage({ loadId }) - }), - ), + Effect.catchTags({ + CropError: ({ reason }) => + Effect.sync(() => { + URL.revokeObjectURL(src) + return Message.FailedLoadCropImage({ loadId, reason }) + }), + }), ) }, }) @@ -68,57 +70,62 @@ export const RevokeCropImage = Command.define("RevokeCropImage", { export const CropAvatarImage = Command.define("CropAvatarImage", { args: { src: Schema.String, crop: CropRect }, - messages: [Message.CompletedCropImage], + messages: [Message.SucceededCropImage, Message.FailedCropImage], execute: ({ src, crop }) => loadImage(src).pipe( Effect.flatMap((image) => cropImage(image, crop, OUTPUT_SIZE)), - Effect.map((blob) => Message.CompletedCropImage({ blob })), - Effect.catch(() => Effect.succeed(Message.CompletedCropImage({ blob: null }))), + Effect.map((blob) => Message.SucceededCropImage({ blob })), + Effect.catchTags({ + CropError: ({ reason }) => Effect.succeed(Message.FailedCropImage({ reason })), + }), ), }) /** `useProfilePictureUpload`: upload the cropped image, then point the user's avatar at it. */ export const UploadAvatar = Command.define("UploadAvatar", { args: { userId: UserId, blob: Schema.instanceOf(Blob) }, - messages: [Message.CompletedUploadAvatar], + messages: [Message.SucceededUploadAvatar, Message.FailedUploadAvatar], execute: ({ userId, blob }) => Effect.gen(function* () { const file = new globalThis.File([blob], "avatar.webp", { type: "image/webp" }) - const key = yield* uploadFile({ type: "user-avatar" }, file) - const avatarUrl = publicUrlOf(key) - if (avatarUrl === null) return false + const avatarUrl = yield* uploadFile(UploadTarget.UserAvatar(), file) yield* updateUser({ id: userId, avatarUrl }) - return true + return Message.SucceededUploadAvatar() }).pipe( - Effect.catch(() => Effect.succeed(false)), - Effect.map((isUploaded) => Message.CompletedUploadAvatar({ isUploaded })), + Effect.catchTags({ + UploadFailedError: ({ problem }) => + Effect.succeed(Message.FailedUploadAvatar({ reason: problem })), + UserNotFoundError: () => + Effect.succeed(Message.FailedUploadAvatar({ reason: "UserNotFound" })), + }), + catchCommon((reason) => Message.FailedUploadAvatar({ reason })), ), }) export const ResetAvatar = Command.define("ResetAvatar", { args: { userId: UserId }, - messages: [Message.CompletedResetAvatar], + messages: [Message.SucceededResetAvatar, Message.FailedResetAvatar], execute: ({ userId }) => - updateUser({ id: userId, avatarUrl: null }).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - Effect.map((isReset) => Message.CompletedResetAvatar({ isReset })), + settleUpdateUser( + updateUser({ id: userId, avatarUrl: null }), + Message.SucceededResetAvatar(), + (reason) => Message.FailedResetAvatar({ reason }), ), }) /** The legacy `onSubmit`: name and timezone in one `user.update`. */ export const SaveProfile = Command.define("SaveProfile", { args: { userId: UserId, values: FormValues }, - messages: [Message.CompletedSaveProfile], + messages: [Message.SucceededSaveProfile, Message.FailedSaveProfile], execute: ({ userId, values }) => - updateUser({ - id: userId, - firstName: values.firstName, - lastName: values.lastName, - timezone: values.timezone, - }).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - Effect.map((isSaved) => Message.CompletedSaveProfile({ isSaved })), + settleUpdateUser( + updateUser({ + id: userId, + firstName: values.firstName, + lastName: values.lastName, + timezone: values.timezone, + }), + Message.SucceededSaveProfile({ values }), + (reason) => Message.FailedSaveProfile({ reason }), ), }) diff --git a/apps/web-foldkit/src/page/my-settings/profile/crop.ts b/apps/web-foldkit/src/page/my-settings/profile/crop.ts index 095d29cb1..fbeab36df 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/crop.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/crop.ts @@ -8,6 +8,10 @@ import { clampCropRect } from "~/utils/image-crop" export const CropRect = Schema.Struct({ x: Schema.Number, y: Schema.Number, size: Schema.Number }) export type CropRect = typeof CropRect.Type +/** `CropError`'s reasons: the image would not load, or the canvas could not crop or encode it. */ +export const CropFailure = Schema.Literals(["LoadFailed", "NoCanvasContext", "EncodeFailed"]) +export type CropFailure = typeof CropFailure.Type + export const DragMode = Schema.Literals(["move", "resize-nw", "resize-ne", "resize-sw", "resize-se"]) export type DragMode = typeof DragMode.Type diff --git a/apps/web-foldkit/src/page/my-settings/profile/form.ts b/apps/web-foldkit/src/page/my-settings/profile/form.ts index f68ed0901..7e3e859f5 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/form.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/form.ts @@ -1,30 +1,32 @@ +import { anyInvalid, makeRules, Rule } from "foldkit/fieldValidation" import type { FormValues, Model } from "./model" -/** The legacy arktype schema (`firstName: "string > 0"`, ...) and its messages. */ -export interface FormErrors { - readonly firstName: string | null - readonly lastName: string | null -} - -const nonEmpty = (field: string, value: string) => (value.length > 0 ? null : `${field} must be non-empty`) +/** `user.update` takes a non-empty first name and at most 100 characters for either name. */ +export const firstNameRules = makeRules({ + required: "First name is required", + rules: [Rule.maxLength(100, "First name must be at most 100 characters")], +}) -export const validate = (values: FormValues): FormErrors => ({ - firstName: nonEmpty("firstName", values.firstName), - lastName: nonEmpty("lastName", values.lastName), +export const lastNameRules = makeRules({ + required: "Last name is required", + rules: [Rule.maxLength(100, "Last name must be at most 100 characters")], }) -/** Form-level `onChange` validation: nothing shows until the first change. */ -export const errorsOf = (model: Model): FormErrors => - model.isDirty ? validate(model.values) : { firstName: null, lastName: null } +export const valuesOf = (model: Model): FormValues => ({ + firstName: model.firstName.value, + lastName: model.lastName.value, + timezone: model.selectedTimezone, +}) -export const canSubmit = (model: Model): boolean => { - const errors = errorsOf(model) - return errors.firstName === null && errors.lastName === null -} +/** Derived: the values differ from the ones the form started from (or last saved). */ +export const isDirty = (model: Model): boolean => + model.firstName.value !== model.defaults.firstName || + model.lastName.value !== model.defaults.lastName || + model.selectedTimezone !== model.defaults.timezone /** `isDisabled={!canSubmit || isSubmitting || !isDirty}` */ export const isSaveDisabled = (model: Model): boolean => - !canSubmit(model) || model.isSubmitting || !model.isDirty + !isDirty(model) || model.save._tag === "Saving" || anyInvalid([model.firstName, model.lastName]) export const initialsOf = (firstName: string, lastName: string) => `${firstName.charAt(0)}${lastName.charAt(0)}` diff --git a/apps/web-foldkit/src/page/my-settings/profile/message.ts b/apps/web-foldkit/src/page/my-settings/profile/message.ts index 2b5059638..c364a2e7c 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/message.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/message.ts @@ -1,12 +1,18 @@ import { Schema } from "effect" import { File } from "foldkit/file" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" import * as Interaction from "../../../ui/aria/interaction" import * as ComboBox from "../../../ui/combo-box" import * as Modal from "../../../ui/modal" -import { UserRow } from "../user" -import { CropImage, DragMode } from "./crop" +import * as DropZone from "../../shared/drop-zone" +import { UploadProblem } from "../../settings/upload" +import { UpdateUserFailure, UserRow } from "../user" +import { CropFailure, CropImage, DragMode } from "./crop" +import { FormValues } from "./model" + +/** Why the avatar did not change: the upload failed, or `user.update` did. */ +export const UploadAvatarFailure = Schema.Union([UploadProblem, UpdateUserFailure]) +export type UploadAvatarFailure = typeof UploadAvatarFailure.Type export const Message = defineMessageUnion({ UpdatedUserRow: { row: Schema.NullOr(UserRow) }, @@ -15,27 +21,28 @@ export const Message = defineMessageUnion({ ChangedLastName: { value: Schema.String }, GotTimezoneMessage: { message: ComboBox.Message }, SubmittedProfile: {}, - CompletedSaveProfile: { isSaved: Schema.Boolean }, + /** `values` are the saved ones, the form's new defaults. */ + SucceededSaveProfile: { values: FormValues }, + FailedSaveProfile: { reason: UpdateUserFailure }, ClickedAvatar: {}, CompletedOpenFilePicker: {}, SelectedAvatarFiles: { files: Schema.Array(File) }, - RejectedAvatarFile: { toast: ToastRequest }, LoadedCropImage: { loadId: Schema.Number, image: CropImage }, - FailedLoadCropImage: { loadId: Schema.Number }, + FailedLoadCropImage: { loadId: Schema.Number, reason: CropFailure }, CompletedRevokeCropImage: {}, - EnteredDropZone: {}, - LeftDropZone: {}, - DraggedFilesOverPage: { isEntering: Schema.Boolean }, - EndedPageDrag: {}, + GotDropZoneMessage: { message: DropZone.Message }, PressedCropHandle: { mode: DragMode, clientX: Schema.Number, clientY: Schema.Number }, MovedCropPointer: { clientX: Schema.Number, clientY: Schema.Number }, ReleasedCropPointer: {}, ClickedCancelCrop: {}, ClickedSaveCrop: {}, - CompletedCropImage: { blob: Schema.NullOr(Schema.instanceOf(Blob)) }, - CompletedUploadAvatar: { isUploaded: Schema.Boolean }, + SucceededCropImage: { blob: Schema.instanceOf(Blob) }, + FailedCropImage: { reason: CropFailure }, + SucceededUploadAvatar: {}, + FailedUploadAvatar: { reason: UploadAvatarFailure }, ClickedResetAvatar: {}, - CompletedResetAvatar: { isReset: Schema.Boolean }, + SucceededResetAvatar: {}, + FailedResetAvatar: { reason: UpdateUserFailure }, GotCropModalMessage: { message: Modal.Message }, GotInteractionMessage: { message: Interaction.Message }, }) diff --git a/apps/web-foldkit/src/page/my-settings/profile/model.ts b/apps/web-foldkit/src/page/my-settings/profile/model.ts index f4d21d6be..66c6b0ad6 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/model.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/model.ts @@ -1,11 +1,14 @@ import { UserId } from "@hazel/schema" import { Schema } from "effect" +import { Field } from "foldkit/fieldValidation" +import { defineTaggedUnion } from "foldkit/schema" import * as Interaction from "../../../ui/aria/interaction" import * as ComboBox from "../../../ui/combo-box" import * as Modal from "../../../ui/modal" +import * as DropZone from "../../shared/drop-zone" import { CropImage } from "./crop" -/** The form's values (`useAppForm` defaultValues and state). */ +/** The form's values (`useAppForm` defaultValues), and what `SaveProfile` sends. */ export const FormValues = Schema.Struct({ firstName: Schema.String, lastName: Schema.String, @@ -14,30 +17,45 @@ export const FormValues = Schema.Struct({ export type FormValues = typeof FormValues.Type /** `AvatarCropModal`'s image state: loading, ready (with the crop) or processing (saving). */ -export const CropState = Schema.Union([ - Schema.TaggedStruct("Idle", {}), +export const CropState = defineTaggedUnion({ + Idle: {}, /** `loadId` tells this load's result apart from an earlier, cancelled pick's. */ - Schema.TaggedStruct("Loading", { loadId: Schema.Number }), - Schema.TaggedStruct("Ready", { image: CropImage }), - Schema.TaggedStruct("Processing", { image: CropImage }), -]) + Loading: { loadId: Schema.Number }, + Ready: { image: CropImage }, + Processing: { image: CropImage }, +}) export type CropState = typeof CropState.Type +/** The profile form's save. */ +export const Save = defineTaggedUnion({ + Idle: {}, + Saving: {}, +}) +export type Save = typeof Save.Type + +/** The avatar write in flight: an upload or a removal, never both. */ +export const AvatarAction = defineTaggedUnion({ + Idle: {}, + Uploading: {}, + Resetting: {}, +}) +export type AvatarAction = typeof AvatarAction.Type + export const Model = Schema.Struct({ /** The user the form was initialized for (the legacy `<form key={user?.id}>`). */ userId: Schema.NullOr(UserId), + /** The values the form started from, rebased on a successful save; the form is dirty when it differs. */ defaults: FormValues, - values: FormValues, - /** TanStack Form: `isDirty` stays true once any field changed; validation starts on the first change. */ - isDirty: Schema.Boolean, + /** Validated as typed; untouched fields stay `NotValidated` and show no error. */ + firstName: Field(Schema.String), + lastName: Field(Schema.String), + selectedTimezone: Schema.NullOr(Schema.String), /** Read once by a Command at init; `null` until then. The form falls back to it without a stored timezone. */ browserTimezone: Schema.NullOr(Schema.String), - isSubmitting: Schema.Boolean, + save: Save, timezone: ComboBox.Model, - isUploading: Schema.Boolean, - isResetting: Schema.Boolean, - isDropTarget: Schema.Boolean, - dragDepth: Schema.Number, + avatarAction: AvatarAction, + dropZone: DropZone.Model, crop: CropState, /** The last crop image load started; each pick takes the next id. */ cropLoadId: Schema.Number, diff --git a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts index 9ab01ec16..b79ffd49f 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts @@ -78,7 +78,8 @@ describe("profile form scene", () => { comboBoxFocusMounted, comboBoxTypedValueMounted, Scene.type("#profile-last-name-input", ""), - Scene.expect(Scene.text("lastName must be non-empty")).toExist(), + Scene.expect(Scene.text("Last name is required")).toExist(), + Scene.expect(Scene.label("Last name")).toHaveAttr("aria-invalid", "true"), Scene.expect(save).toBeDisabled(), ) }) @@ -106,9 +107,16 @@ describe("profile save scene", () => { Scene.click(save), Scene.Command.expectExact(SaveProfile), Scene.expect(Scene.role("button", { name: "Saving..." })).toBeDisabled(), - Scene.Command.resolve(SaveProfile, Message.CompletedSaveProfile({ isSaved: true })), + Scene.Command.resolve( + SaveProfile, + Message.SucceededSaveProfile({ + values: { firstName: "Augusta Ada", lastName: "Lovelace", timezone: null }, + }), + ), Scene.expectOutMessage(toastOf("success", "Profile updated successfully")), Scene.expect(firstName).toHaveValue("Augusta Ada"), + // Saved: the form is clean again. + Scene.expect(save).toBeDisabled(), ) }) @@ -120,8 +128,14 @@ describe("profile save scene", () => { comboBoxTypedValueMounted, Scene.type(firstName, "Augusta Ada"), Scene.click(save), - Scene.Command.resolve(SaveProfile, Message.CompletedSaveProfile({ isSaved: false })), - Scene.expectOutMessage(toastOf("error", "Failed to update profile")), + Scene.Command.resolve(SaveProfile, Message.FailedSaveProfile({ reason: "Timeout" })), + Scene.expectOutMessage( + toastOf( + "error", + "Failed to update profile", + "The server is taking too long to respond. Please try again.", + ), + ), Scene.expect(save).toBeEnabled(), Scene.expect(firstName).toHaveValue("Augusta Ada"), ) @@ -139,8 +153,8 @@ describe("avatar scene", () => { comboBoxTypedValueMounted, Scene.click(reset), Scene.expect(Scene.role("button", { name: "Removing..." })).toBeDisabled(), - Scene.Command.resolve(ResetAvatar, Message.CompletedResetAvatar({ isReset: false })), - Scene.expectOutMessage(toastOf("error", "Failed to remove profile picture")), + Scene.Command.resolve(ResetAvatar, Message.FailedResetAvatar({ reason: "Unexpected" })), + Scene.expectOutMessage(toastOf("error", "Failed to remove profile picture", "Please try again.")), Scene.expect(reset).toBeEnabled(), ) }) @@ -170,14 +184,14 @@ describe("avatar crop dialog scene", () => { ...openCrop, Scene.click(saveCrop), Scene.expect(Scene.within(dialog, Scene.role("button", { name: "Saving..." }))).toBeDisabled(), - Scene.Command.resolve(CropAvatarImage, Message.CompletedCropImage({ blob: new Blob(["webp"]) })), + Scene.Command.resolve(CropAvatarImage, Message.SucceededCropImage({ blob: new Blob(["webp"]) })), Scene.expect(dialog).toBeAbsent(), Scene.Mount.expectEnded(Modal.PortalModal), Scene.expect(Scene.text("Uploading...")).toExist(), Scene.expect(Scene.role("button", { name: "Remove photo" })).toBeDisabled(), Scene.Command.resolveAll( [RevokeCropImage, Message.CompletedRevokeCropImage()], - [UploadAvatar, Message.CompletedUploadAvatar({ isUploaded: true })], + [UploadAvatar, Message.SucceededUploadAvatar()], ), Scene.expectOutMessage( PageOutMessage.RequestedCurrentUserRefresh({ @@ -188,6 +202,46 @@ describe("avatar crop dialog scene", () => { ) }) + test("an upload rejected as too large shows that, then re-enables the avatar", () => { + Scene.scene( + config, + Scene.given(init(undefined, shared).model), + ...openCrop, + Scene.click(saveCrop), + Scene.Command.resolve(CropAvatarImage, Message.SucceededCropImage({ blob: new Blob(["webp"]) })), + Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Command.resolveAll( + [RevokeCropImage, Message.CompletedRevokeCropImage()], + [UploadAvatar, Message.FailedUploadAvatar({ reason: "TooLarge" })], + ), + Scene.expectOutMessage(toastOf("error", "File too large", "File size must be less than 5MB")), + Scene.expect(Scene.text("Uploading...")).toBeAbsent(), + Scene.expect(Scene.role("button", { name: "Remove photo" })).toBeEnabled(), + ) + }) + + test("an unconfigured upload says so instead of a generic failure", () => { + Scene.scene( + config, + Scene.given(init(undefined, shared).model), + ...openCrop, + Scene.click(saveCrop), + Scene.Command.resolve(CropAvatarImage, Message.SucceededCropImage({ blob: new Blob(["webp"]) })), + Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Command.resolveAll( + [RevokeCropImage, Message.CompletedRevokeCropImage()], + [UploadAvatar, Message.FailedUploadAvatar({ reason: "NotConfigured" })], + ), + Scene.expectOutMessage( + toastOf( + "error", + "Configuration error", + "Image upload is not configured. Please contact support.", + ), + ), + ) + }) + test("Cancel closes the dialog and frees the image", () => { Scene.scene( config, diff --git a/apps/web-foldkit/src/page/my-settings/profile/profile.story.test.ts b/apps/web-foldkit/src/page/my-settings/profile/profile.story.test.ts index 95426cc6e..fa8165986 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/profile.story.test.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/profile.story.test.ts @@ -16,8 +16,9 @@ import { UploadAvatar, } from "./command" import { dragTo } from "./crop" -import { errorsOf, isSaveDisabled } from "./form" +import { isDirty, isSaveDisabled, valuesOf } from "./form" import { Message } from "./message" +import { AvatarAction, CropState, Save } from "./model" import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" @@ -43,13 +44,16 @@ const shared: Shared = { } const pageUpdate = (current: Parameters<typeof update>[0], next: Message) => update(current, next, shared) const initial = () => init(undefined, shared).model +const errorToastOf = (title: string, description: string | null) => + PageOutMessage.RequestedToast({ toast: { intent: "error", title, description } }) describe("profile form", () => { test("starts clean with Save disabled and no errors", () => { const current = initial() - expect(current.values).toMatchObject({ firstName: "Ada", lastName: "Lovelace" }) + expect(valuesOf(current)).toMatchObject({ firstName: "Ada", lastName: "Lovelace" }) expect(isSaveDisabled(current)).toBe(true) - expect(errorsOf(current)).toEqual({ firstName: null, lastName: null }) + expect(current.firstName._tag).toBe("NotValidated") + expect(current.lastName._tag).toBe("NotValidated") }) test("an edited name enables Save, and saving sends the form values", () => { @@ -59,24 +63,64 @@ describe("profile form", () => { message(Message.ChangedFirstName({ value: "Augusta Ada" })), model((current) => expect(isSaveDisabled(current)).toBe(false)), message(Message.SubmittedProfile()), - model((current) => expect(current.isSubmitting).toBe(true)), - Command.resolve(SaveProfile, Message.CompletedSaveProfile({ isSaved: true })), + model((current) => expect(current.save).toEqual(Save.Saving())), + Command.resolve( + SaveProfile, + Message.SucceededSaveProfile({ + values: { firstName: "Augusta Ada", lastName: "Lovelace", timezone: null }, + }), + ), expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "success", title: "Profile updated successfully", description: null }, }), ), - model((current) => expect(current.isSubmitting).toBe(false)), + model((current) => { + expect(current.save).toEqual(Save.Idle()) + // The saved values are the new defaults: clean again, Save disabled. + expect(current.defaults.firstName).toBe("Augusta Ada") + expect(isDirty(current)).toBe(false) + expect(isSaveDisabled(current)).toBe(true) + }), ) }) - test("an empty last name shows the arktype message and blocks Save", () => { + test("an empty last name shows the field's message and blocks Save", () => { story( pageUpdate, given(initial()), message(Message.ChangedLastName({ value: "" })), model((current) => { - expect(errorsOf(current).lastName).toBe("lastName must be non-empty") + expect(current.lastName).toMatchObject({ _tag: "Invalid", errors: ["Last name is required"] }) + expect(isSaveDisabled(current)).toBe(true) + }), + ) + }) + + test("a name over 100 characters is rejected before the server sees it", () => { + story( + pageUpdate, + given(initial()), + message(Message.ChangedFirstName({ value: "a".repeat(101) })), + model((current) => { + expect(current.firstName).toMatchObject({ + _tag: "Invalid", + errors: ["First name must be at most 100 characters"], + }) + expect(isSaveDisabled(current)).toBe(true) + }), + ) + }) + + test("reverting an edit makes the form clean again", () => { + story( + pageUpdate, + given(initial()), + message(Message.ChangedFirstName({ value: "Augusta" })), + model((current) => expect(isDirty(current)).toBe(true)), + message(Message.ChangedFirstName({ value: "Ada" })), + model((current) => { + expect(isDirty(current)).toBe(false) expect(isSaveDisabled(current)).toBe(true) }), ) @@ -88,15 +132,7 @@ describe("profile form", () => { pageUpdate, given(initial()), message(Message.SelectedAvatarFiles({ files: [file] })), - expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { - intent: "error", - title: "Invalid file type", - description: "Please select a JPEG, PNG, or WebP image", - }, - }), - ), + expectOutMessage(errorToastOf("Invalid file type", "Please select a JPEG, PNG, or WebP image")), model((current) => expect(current.cropModal.isOpen).toBe(false)), ) }) @@ -108,8 +144,8 @@ describe("avatar", () => { pageUpdate, given(initial()), message(Message.ClickedResetAvatar()), - model((current) => expect(current.isResetting).toBe(true)), - Command.resolve(ResetAvatar, Message.CompletedResetAvatar({ isReset: true })), + model((current) => expect(current.avatarAction).toEqual(AvatarAction.Resetting())), + Command.resolve(ResetAvatar, Message.SucceededResetAvatar()), expectOutMessage( PageOutMessage.RequestedCurrentUserRefresh({ toast: { @@ -119,7 +155,7 @@ describe("avatar", () => { }, }), ), - model((current) => expect(current.isResetting).toBe(false)), + model((current) => expect(current.avatarAction).toEqual(AvatarAction.Idle())), ) }) }) @@ -153,36 +189,39 @@ describe("profile save", () => { given(start), message(Message.ChangedLastName({ value: "Byron" })), message(Message.SubmittedProfile()), - Command.expectExact(SaveProfile({ userId: ada, values: { ...start.values, lastName: "Byron" } })), - Command.resolve(SaveProfile, Message.CompletedSaveProfile({ isSaved: true })), + Command.expectExact( + SaveProfile({ userId: ada, values: { ...valuesOf(start), lastName: "Byron" } }), + ), + Command.resolve( + SaveProfile, + Message.SucceededSaveProfile({ values: { ...valuesOf(start), lastName: "Byron" } }), + ), ) }) - test("a failed save re-enables the form, keeps the edit and toasts the error", () => { + test("a failed save re-enables the form, keeps the edit and toasts the reason", () => { story( pageUpdate, given(initial()), message(Message.ChangedFirstName({ value: "Augusta Ada" })), message(Message.SubmittedProfile()), - Command.resolve(SaveProfile, Message.CompletedSaveProfile({ isSaved: false })), + Command.resolve(SaveProfile, Message.FailedSaveProfile({ reason: "Offline" })), expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to update profile", description: null }, - }), + errorToastOf("Failed to update profile", "Check your internet connection and try again."), ), model((current) => { - expect(current.isSubmitting).toBe(false) - expect(current.values.firstName).toBe("Augusta Ada") + expect(current.save).toEqual(Save.Idle()) + expect(current.firstName.value).toBe("Augusta Ada") expect(isSaveDisabled(current)).toBe(false) }), ) }) test("a second submit while saving is ignored", () => { - const saving = { ...initial(), isDirty: true, isSubmitting: true } + const edited = pageUpdate(initial(), Message.ChangedFirstName({ value: "Augusta" })).model story( pageUpdate, - given(saving), + given({ ...edited, save: Save.Saving() }), message(Message.SubmittedProfile()), Command.expectNone(), expectNoOutMessage(), @@ -196,21 +235,42 @@ describe("profile save", () => { message(Message.ChangedFirstName({ value: "" })), message(Message.SubmittedProfile()), Command.expectNone(), - model((current) => expect(current.isSubmitting).toBe(false)), + model((current) => expect(current.save).toEqual(Save.Idle())), + ) + }) + + test("submitting validates untouched fields too", () => { + const nameless: Shared = { + ...shared, + currentUser: shared.currentUser === null ? null : { ...shared.currentUser, lastName: "" }, + } + const start = init(undefined, nameless).model + story( + (current: Parameters<typeof update>[0], next: Message) => update(current, next, nameless), + given(start), + message(Message.ChangedFirstName({ value: "Augusta" })), + message(Message.SubmittedProfile()), + Command.expectNone(), + model((current) => + expect(current.lastName).toMatchObject({ + _tag: "Invalid", + errors: ["Last name is required"], + }), + ), ) }) test("the browser timezone is read by a Command and fills an untouched form without a stored one", () => { const started = init(undefined, shared) expect(started.commands?.map((command) => command.name)).toEqual([ReadProfileBrowserTimezone.name]) - expect(started.model.values.timezone).toBeNull() + expect(started.model.selectedTimezone).toBeNull() story( pageUpdate, given(started.model), message(Message.DetectedBrowserTimezone({ browserTimezone: "Europe/Vienna" })), model((current) => { - expect(current.values.timezone).toBe("Europe/Vienna") - expect(current.isDirty).toBe(false) + expect(current.selectedTimezone).toBe("Europe/Vienna") + expect(isDirty(current)).toBe(false) }), ) story( @@ -218,7 +278,7 @@ describe("profile save", () => { given(started.model), message(Message.ChangedFirstName({ value: "Augusta" })), message(Message.DetectedBrowserTimezone({ browserTimezone: "Europe/Vienna" })), - model((current) => expect(current.values.timezone).toBeNull()), + model((current) => expect(current.selectedTimezone).toBeNull()), ) }) @@ -237,8 +297,8 @@ describe("profile save", () => { message(Message.ChangedFirstName({ value: "Augusta" })), message(Message.UpdatedUserRow({ row })), model((current) => { - expect(current.values.firstName).toBe("Augusta") - expect(current.values.timezone).not.toBe("Asia/Tokyo") + expect(current.firstName.value).toBe("Augusta") + expect(current.selectedTimezone).not.toBe("Asia/Tokyo") }), ) }) @@ -254,6 +314,7 @@ describe("avatar upload", () => { Command.expectExact(LoadCropImage), Command.resolve(LoadCropImage, Message.LoadedCropImage({ loadId: 1, image: loaded })), ] + const uploading = () => ({ ...initial(), avatarAction: AvatarAction.Uploading() }) test("pick, crop and upload refreshes the current user and frees the object URL", () => { story( @@ -266,51 +327,67 @@ describe("avatar upload", () => { }), message(Message.ClickedSaveCrop()), Command.expectExact(CropAvatarImage({ src: "blob:me", crop: loaded.crop })), - Command.resolve(CropAvatarImage, Message.CompletedCropImage({ blob })), + Command.resolve(CropAvatarImage, Message.SucceededCropImage({ blob })), model((current) => { expect(current.cropModal.isOpen).toBe(false) - expect(current.isUploading).toBe(true) + expect(current.avatarAction).toEqual(AvatarAction.Uploading()) }), Command.expectExact(RevokeCropImage({ src: "blob:me" }), UploadAvatar), Command.resolveAll( [RevokeCropImage, Message.CompletedRevokeCropImage()], - [UploadAvatar, Message.CompletedUploadAvatar({ isUploaded: true })], + [UploadAvatar, Message.SucceededUploadAvatar()], ), expectOutMessage( PageOutMessage.RequestedCurrentUserRefresh({ toast: { intent: "success", title: "Profile picture updated", description: null }, }), ), - model((current) => expect(current.isUploading).toBe(false)), + model((current) => expect(current.avatarAction).toEqual(AvatarAction.Idle())), + ) + }) + + test("a failed upload clears the spinner and toasts the reason", () => { + story( + pageUpdate, + given(uploading()), + message(Message.FailedUploadAvatar({ reason: "Unavailable" })), + expectOutMessage(errorToastOf("Upload failed", "Please try again in a moment.")), + model((current) => expect(current.avatarAction).toEqual(AvatarAction.Idle())), ) }) - test("a failed upload clears the spinner and toasts", () => { + test("an upload the storage rejects as too large says so, not a generic failure", () => { story( pageUpdate, - given({ ...initial(), isUploading: true }), - message(Message.CompletedUploadAvatar({ isUploaded: false })), + given(uploading()), + message(Message.FailedUploadAvatar({ reason: "TooLarge" })), + expectOutMessage(errorToastOf("File too large", "File size must be less than 5MB")), + ) + }) + + test("an unconfigured upload bucket says so", () => { + story( + pageUpdate, + given(uploading()), + message(Message.FailedUploadAvatar({ reason: "NotConfigured" })), expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { - intent: "error", - title: "Upload failed", - description: "Failed to update profile picture. Please try again.", - }, - }), + errorToastOf( + "Configuration error", + "Image upload is not configured. Please contact support.", + ), ), - model((current) => expect(current.isUploading).toBe(false)), ) }) - test("a failed crop returns to the editable image", () => { + test("a failed crop returns to the editable image and toasts", () => { story( pageUpdate, given(initial()), ...openCrop(), message(Message.ClickedSaveCrop()), - Command.resolve(CropAvatarImage, Message.CompletedCropImage({ blob: null })), + Command.resolve(CropAvatarImage, Message.FailedCropImage({ reason: "EncodeFailed" })), Command.expectNone(), + expectOutMessage(errorToastOf("Could not crop the image", "Please try again.")), model((current) => { expect(current.crop._tag).toBe("Ready") expect(current.cropModal.isOpen).toBe(true) @@ -323,8 +400,9 @@ describe("avatar upload", () => { pageUpdate, given(initial()), message(Message.SelectedAvatarFiles({ files: [png] })), - Command.resolve(LoadCropImage, Message.FailedLoadCropImage({ loadId: 1 })), + Command.resolve(LoadCropImage, Message.FailedLoadCropImage({ loadId: 1, reason: "LoadFailed" })), Command.expectNone(), + expectOutMessage(errorToastOf("Could not open the image", "Please choose another image.")), model((current) => { expect(current.cropModal.isOpen).toBe(false) expect(current.crop._tag).toBe("Idle") @@ -360,11 +438,12 @@ describe("avatar upload", () => { const first = pageUpdate(initial(), Message.SelectedAvatarFiles({ files: [png] })).model const cancelled = pageUpdate(first, Message.ClickedCancelCrop()).model const repicked = pageUpdate(cancelled, Message.SelectedAvatarFiles({ files: [second] })).model - expect(repicked.crop).toEqual({ _tag: "Loading", loadId: 2 }) + expect(repicked.crop).toEqual(CropState.Loading({ loadId: 2 })) const stale = pageUpdate(repicked, Message.LoadedCropImage({ loadId: 1, image: loaded })) - expect(stale.model.crop).toEqual({ _tag: "Loading", loadId: 2 }) + expect(stale.model.crop).toEqual(CropState.Loading({ loadId: 2 })) expect(stale.commands).toMatchObject([{ name: RevokeCropImage.name, args: { src: "blob:me" } }]) - expect(pageUpdate(repicked, Message.FailedLoadCropImage({ loadId: 1 })).model.cropModal.isOpen).toBe(true) + const staleFailure = Message.FailedLoadCropImage({ loadId: 1, reason: "LoadFailed" }) + expect(pageUpdate(repicked, staleFailure).model.cropModal.isOpen).toBe(true) }) test("a file over 5MB is rejected with a toast", () => { @@ -374,15 +453,7 @@ describe("avatar upload", () => { given(initial()), message(Message.SelectedAvatarFiles({ files: [huge] })), Command.expectNone(), - expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { - intent: "error", - title: "File too large", - description: "Image must be less than 5MB", - }, - }), - ), + expectOutMessage(errorToastOf("File too large", "File size must be less than 5MB")), ) }) @@ -394,36 +465,47 @@ describe("avatar upload", () => { Command.expectExact(OpenFilePicker()), Command.resolve(OpenFilePicker, Message.CompletedOpenFilePicker()), ) + story(pageUpdate, given(uploading()), message(Message.ClickedAvatar()), Command.expectNone()) + }) + + test("a crop is not saved while a removal runs", () => { story( pageUpdate, - given({ ...initial(), isUploading: true }), - message(Message.ClickedAvatar()), + given({ + ...initial(), + crop: CropState.Ready({ image: loaded }), + avatarAction: AvatarAction.Resetting(), + }), + message(Message.ClickedSaveCrop()), Command.expectNone(), + model((current) => expect(current.crop._tag).toBe("Ready")), ) }) }) describe("avatar reset", () => { - test("a failed reset clears the pending state and toasts", () => { + test("a failed reset clears the pending state and toasts the reason", () => { story( pageUpdate, given(initial()), message(Message.ClickedResetAvatar()), - Command.resolve(ResetAvatar, Message.CompletedResetAvatar({ isReset: false })), - expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Failed to remove profile picture", description: null }, - }), - ), - model((current) => expect(current.isResetting).toBe(false)), + Command.resolve(ResetAvatar, Message.FailedResetAvatar({ reason: "UserNotFound" })), + expectOutMessage(errorToastOf("Failed to remove profile picture", "Please sign in again.")), + model((current) => expect(current.avatarAction).toEqual(AvatarAction.Idle())), ) }) - // ClickedResetAvatar is guarded on isResetting in update, not only by the view's disabled button. - test("a second reset while one is pending is ignored", () => { + // ClickedResetAvatar is guarded on the avatar action in update, not only by the view's disabled button. + test("a reset while another avatar write is pending is ignored", () => { + story( + pageUpdate, + given({ ...initial(), avatarAction: AvatarAction.Resetting() }), + message(Message.ClickedResetAvatar()), + Command.expectNone(), + ) story( pageUpdate, - given({ ...initial(), isResetting: true }), + given({ ...initial(), avatarAction: AvatarAction.Uploading() }), message(Message.ClickedResetAvatar()), Command.expectNone(), ) diff --git a/apps/web-foldkit/src/page/my-settings/profile/subscription.ts b/apps/web-foldkit/src/page/my-settings/profile/subscription.ts index 8bdf0679a..d2bb18da8 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/subscription.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/subscription.ts @@ -1,13 +1,12 @@ import { UserId } from "@hazel/schema" -import { Option, Schema, Stream } from "effect" +import { Schema, Stream } from "effect" import { Dom, Subscription } from "foldkit" import type { PageSubscriptionInput } from "../../contract" +import * as DropZone from "../../shared/drop-zone" import { userRowStream } from "../user-stream" import { Message } from "./message" import type { Model } from "./model" -import { interaction } from "./update" - -const hasFiles = (event: DragEvent) => event.dataTransfer?.types.includes("Files") ?? false +import { interaction, toDropZoneMessage } from "./update" const isDraggingCrop = (model: Model) => model.crop._tag === "Ready" && model.crop.image.drag !== null @@ -48,37 +47,8 @@ const page = Subscription.make<PageSubscriptionInput<Model>, Message>()((entry) : Stream.empty, }, ), - // `useDragDetection`: file drags anywhere on the page. - pageDrag: Subscription.persistentEntry( - Stream.mergeAll( - [ - Dom.streamFromEventFilterMap({ - target: document, - type: "dragenter", - filterMapEvent: (event) => - hasFiles(event) - ? Option.some(Message.DraggedFilesOverPage({ isEntering: true })) - : Option.none(), - }), - Dom.streamFromEvent({ - target: document, - type: "dragleave", - mapEvent: () => Message.DraggedFilesOverPage({ isEntering: false }), - }), - Dom.streamFromEvent({ - target: document, - type: "drop", - mapEvent: () => Message.EndedPageDrag(), - }), - Dom.streamFromEvent({ - target: document, - type: "dragend", - mapEvent: () => Message.EndedPageDrag(), - }), - ], - { concurrency: "unbounded" }, - ), - ), + // `useDragDetection`: file drags anywhere on the page, for the "Drop image" hint. + pageDrag: Subscription.persistentEntry(DropZone.pageDragStream(toDropZoneMessage)), })) export const subscriptions = Subscription.aggregate(page, interaction.subscriptions) diff --git a/apps/web-foldkit/src/page/my-settings/profile/update.ts b/apps/web-foldkit/src/page/my-settings/profile/update.ts index 06dacfa6d..5de4cfebb 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/update.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/update.ts @@ -1,16 +1,18 @@ -import { Array, Option } from "effect" -import { Command } from "foldkit" +import { Array, Option, Schema } from "effect" +import { Update } from "foldkit" +import { anyInvalid, NotValidated, validate } from "foldkit/fieldValidation" import { modifyFields } from "foldkit/struct" -import { errorToast, successToast } from "../../../data/actions" -import type { ToastRequest } from "../../../overlay/toasts" +import { failureMessage } from "~/lib/error-messages" +import { errorToast, errorToastOf, successToast } from "../../../data/actions" import * as Interaction from "../../../ui/aria/interaction" import * as ComboBox from "../../../ui/combo-box" import * as Modal from "../../../ui/modal" import * as TimezoneSelect from "../../../ui/timezone-select" -import type { PageReturn, Shared } from "../../contract" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" -import { embedInteraction } from "../shared" -import type { UserRow } from "../user" +import { UploadProblem, uploadProblemMessage, validateUploadFile } from "../../settings/upload" +import * as DropZone from "../../shared/drop-zone" +import { type UpdateUserFailure, type UserRow, updateUserFailureCopy } from "../user" import { CropAvatarImage, LoadCropImage, @@ -22,32 +24,35 @@ import { UploadAvatar, } from "./command" import { type CropImage, dragTo } from "./crop" -import { isSaveDisabled } from "./form" -import { Message } from "./message" -import type { CropState, FormValues, Model } from "./model" +import { firstNameRules, isDirty, lastNameRules, valuesOf } from "./form" +import { Message, type UploadAvatarFailure } from "./message" +import { AvatarAction, CropState, type FormValues, type Model, Save } from "./model" type Return = PageReturn<Model, Message> +type Step = Update.Step<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) export const toTimezoneMessage = (message: ComboBox.Message) => Message.GotTimezoneMessage({ message }) export const toCropModalMessage = (message: Modal.Message) => Message.GotCropModalMessage({ message }) - -const MAX_FILE_SIZE = 5 * 1024 * 1024 -const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] - -const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) +export const toDropZoneMessage = (message: DropZone.Message) => Message.GotDropZoneMessage({ message }) /** The avatar changed: `refreshCurrentUser()` re-reads `user.me`, then the success toast. */ const refreshUser = (title: string) => PageOutMessage.RequestedCurrentUserRefresh({ toast: successToast(title) }) -const merge = (first: Return, second: Return): Return => ({ - model: second.model, - commands: [...(first.commands ?? []), ...(second.commands ?? [])], - ...(second.outMessage === undefined ? {} : { outMessage: second.outMessage }), -}) +/** `title`, with the reason's description (never server text). */ +const updateUserToast = (title: string, reason: UpdateUserFailure) => + errorToast(title, failureMessage(reason, updateUserFailureCopy).description ?? null) + +const isUploadProblem = Schema.is(UploadProblem) + +const uploadAvatarToast = (reason: UploadAvatarFailure) => + isUploadProblem(reason) + ? errorToastOf(uploadProblemMessage("avatar", reason)) + : updateUserToast("Upload failed", reason) /** `<form key={user?.id}>`: a new user starts a fresh form from their values. */ const formFor = (model: Model, shared: Shared, row: UserRow | null): Model => { @@ -60,29 +65,23 @@ const formFor = (model: Model, shared: Shared, row: UserRow | null): Model => { return modifyFields(model, { userId: () => user?.id ?? null, defaults: () => defaults, - values: () => defaults, - isDirty: () => false, + firstName: () => NotValidated({ value: defaults.firstName }), + lastName: () => NotValidated({ value: defaults.lastName }), + selectedTimezone: () => defaults.timezone, timezone: () => TimezoneSelect.init({ id: "profile-timezone", value: defaults.timezone ?? undefined }), }) } -type FormValuesTransform = Partial<{ - readonly [K in keyof FormValues]: (value: FormValues[K]) => FormValues[K] -}> - -const changed = (model: Model, transform: FormValuesTransform): Model => - modifyFields(model, { values: (values) => modifyFields(values, transform), isDirty: () => true }) - const cropSrc = (crop: CropState) => crop._tag === "Ready" || crop._tag === "Processing" ? crop.image.src : null /** Closes the crop modal and releases the image (`handleCropModalOpenChange(false)`). */ -const closeCrop = (model: Model): Return => { +const closeCrop: Step = (model) => { const src = cropSrc(model.crop) return { model: modifyFields(model, { - crop: () => ({ _tag: "Idle" }), + crop: () => CropState.Idle(), cropModal: (modal) => modifyFields(modal, { isOpen: () => false }), }), commands: src === null ? [] : [RevokeCropImage({ src })], @@ -93,7 +92,7 @@ const closeCrop = (model: Model): Return => { const mapReadyImage = (model: Model, f: (image: CropImage) => CropImage): Model => { const crop = model.crop return crop._tag === "Ready" - ? modifyFields(model, { crop: () => ({ _tag: "Ready", image: f(crop.image) }) }) + ? modifyFields(model, { crop: () => CropState.Ready({ image: f(crop.image) }) }) : model } @@ -103,16 +102,15 @@ export const init = (_route: unknown, shared: Shared): Return => ({ { userId: null, defaults: { firstName: "", lastName: "", timezone: null }, - values: { firstName: "", lastName: "", timezone: null }, - isDirty: false, + firstName: NotValidated({ value: "" }), + lastName: NotValidated({ value: "" }), + selectedTimezone: null, browserTimezone: null, - isSubmitting: false, + save: Save.Idle(), timezone: TimezoneSelect.init({ id: "profile-timezone" }), - isUploading: false, - isResetting: false, - isDropTarget: false, - dragDepth: 0, - crop: { _tag: "Idle" }, + avatarAction: AvatarAction.Idle(), + dropZone: DropZone.init(), + crop: CropState.Idle(), cropLoadId: 0, cropModal: Modal.init("avatar-crop"), interaction: Interaction.init(), @@ -129,22 +127,18 @@ const selectFile = (model: Model, files: ReadonlyArray<File>): Return => Option.match(Array.head(files), { onNone: () => ({ model }), onSome: (file) => { - if (!ALLOWED_TYPES.includes(file.type)) + const problem = validateUploadFile("avatar", file) + if (problem !== null) return { model, - outMessage: toast( - errorToast("Invalid file type", "Please select a JPEG, PNG, or WebP image"), - ), - } - if (file.size > MAX_FILE_SIZE) - return { - model, - outMessage: toast(errorToast("File too large", "Image must be less than 5MB")), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(uploadProblemMessage("avatar", problem)), + }), } const loadId = model.cropLoadId + 1 return { model: modifyFields(model, { - crop: () => ({ _tag: "Loading", loadId }), + crop: () => CropState.Loading({ loadId }), cropLoadId: () => loadId, cropModal: (modal) => modifyFields(modal, { isOpen: () => true }), }), @@ -153,75 +147,111 @@ const selectFile = (model: Model, files: ReadonlyArray<File>): Return => }, }) -const foldCropModal = (model: Model, message: Modal.Message): Return => { - const result = Modal.update(model.cropModal, message) - const next = modifyFields(model, { cropModal: () => result.model }) - const commands = Command.mapMessages(result.commands ?? [], toCropModalMessage) - return model.cropModal.isOpen && !result.model.isOpen - ? merge({ model: next, commands }, closeCrop(next)) - : { model: next, commands } -} +const foldCropModal = Update.foldChild({ + update: Modal.update, + read: (model: Model) => Option.some(model.cropModal), + write: (model: Model, cropModal: Modal.Model): Model => + modifyFields(model, { cropModal: () => cropModal }), + toParentMessage: toCropModalMessage, +}) + +/** Closing the dialog from inside (Escape, the backdrop) also releases the image. */ +const closedCropModal = + (wasOpen: boolean): Step => + (model) => + wasOpen && !model.cropModal.isOpen ? closeCrop(model) : { model } + +const foldTimezone = Update.foldChild({ + update: ComboBox.update, + read: (model: Model) => Option.some(model.timezone), + write: (model: Model, timezone: ComboBox.Model): Model => + modifyFields(model, { timezone: () => timezone }), + toParentMessage: toTimezoneMessage, + foldOutMessage: (outMessage) => (model: Model) => ({ + model: modifyFields(model, { selectedTimezone: () => outMessage.key }), + }), +}) + +const foldDropZone = Update.foldChild({ + update: DropZone.update, + read: (model: Model) => Option.some(model.dropZone), + write: (model: Model, dropZone: DropZone.Model): Model => + modifyFields(model, { dropZone: () => dropZone }), + toParentMessage: toDropZoneMessage, +}) -const foldTimezone = (model: Model, message: ComboBox.Message): Return => { - const result = ComboBox.update(model.timezone, message) - const next = modifyFields(model, { timezone: () => result.model }) - const commands = Command.mapMessages(result.commands ?? [], toTimezoneMessage) - const key = result.outMessage?.key - return { model: key === undefined ? next : changed(next, { timezone: () => key }), commands } +/** Validates every field (the submit shows each error, touched or not); saves a valid, changed form. */ +const submit = (model: Model): Return => { + const validated = modifyFields(model, { + firstName: (field) => validate(firstNameRules)(field.value), + lastName: (field) => validate(lastNameRules)(field.value), + }) + if ( + model.userId === null || + model.save._tag === "Saving" || + !isDirty(model) || + anyInvalid([validated.firstName, validated.lastName]) + ) + return { model: validated } + return { + model: modifyFields(validated, { save: () => Save.Saving() }), + commands: [SaveProfile({ userId: model.userId, values: valuesOf(model) })], + } } export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { UpdatedUserRow: ({ row }) => - model.isDirty || row === null || !row.timezone || row.timezone === model.defaults.timezone + isDirty(model) || row === null || !row.timezone || row.timezone === model.defaults.timezone ? { model } : { model: formFor(model, shared, row) }, DetectedBrowserTimezone: ({ browserTimezone }) => { const next = modifyFields(model, { browserTimezone: () => browserTimezone }) // Only an untouched form without a stored timezone falls back to the browser's. - return model.isDirty || model.defaults.timezone !== null + return isDirty(model) || model.defaults.timezone !== null ? { model: next } : { model: formFor(next, shared, null) } }, - ChangedFirstName: ({ value }) => ({ model: changed(model, { firstName: () => value }) }), - ChangedLastName: ({ value }) => ({ model: changed(model, { lastName: () => value }) }), + ChangedFirstName: ({ value }) => ({ + model: modifyFields(model, { firstName: () => validate(firstNameRules)(value) }), + }), + ChangedLastName: ({ value }) => ({ + model: modifyFields(model, { lastName: () => validate(lastNameRules)(value) }), + }), GotTimezoneMessage: ({ message: child }) => foldTimezone(model, child), - SubmittedProfile: () => - model.userId === null || isSaveDisabled(model) - ? { model } - : { - model: modifyFields(model, { isSubmitting: () => true }), - commands: [SaveProfile({ userId: model.userId, values: model.values })], - }, - CompletedSaveProfile: ({ isSaved }) => ({ - model: modifyFields(model, { isSubmitting: () => false }), - outMessage: isSaved - ? toast(successToast("Profile updated successfully")) - : toast(errorToast("Failed to update profile")), + SubmittedProfile: () => submit(model), + SucceededSaveProfile: ({ values }) => ({ + model: modifyFields(model, { save: () => Save.Idle(), defaults: () => values }), + outMessage: PageOutMessage.RequestedToast({ + toast: successToast("Profile updated successfully"), + }), }), - ClickedAvatar: () => (model.isUploading ? { model } : { model, commands: [OpenFilePicker()] }), + FailedSaveProfile: ({ reason }) => ({ + model: modifyFields(model, { save: () => Save.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: updateUserToast("Failed to update profile", reason), + }), + }), + ClickedAvatar: () => + model.avatarAction._tag === "Uploading" ? { model } : { model, commands: [OpenFilePicker()] }, CompletedOpenFilePicker: () => ({ model }), SelectedAvatarFiles: ({ files }) => - selectFile(modifyFields(model, { isDropTarget: () => false, dragDepth: () => 0 }), files), - RejectedAvatarFile: ({ toast: request }) => ({ - model, - outMessage: toast(request), - }), + selectFile(modifyFields(model, { dropZone: () => DropZone.reset() }), files), LoadedCropImage: ({ loadId, image }) => model.crop._tag === "Loading" && model.crop.loadId === loadId - ? { model: modifyFields(model, { crop: () => ({ _tag: "Ready", image }) }) } + ? { model: modifyFields(model, { crop: () => CropState.Ready({ image }) }) } : { model, commands: [RevokeCropImage({ src: image.src })] }, FailedLoadCropImage: ({ loadId }) => - model.crop._tag === "Loading" && model.crop.loadId === loadId ? closeCrop(model) : { model }, + model.crop._tag === "Loading" && model.crop.loadId === loadId + ? Update.withOutMessage( + closeCrop(model), + PageOutMessage.RequestedToast({ + toast: errorToast("Could not open the image", "Please choose another image."), + }), + ) + : { model }, CompletedRevokeCropImage: () => ({ model }), - EnteredDropZone: () => ({ model: modifyFields(model, { isDropTarget: () => true }) }), - LeftDropZone: () => ({ model: modifyFields(model, { isDropTarget: () => false }) }), - DraggedFilesOverPage: ({ isEntering }) => ({ - model: modifyFields(model, { dragDepth: (depth) => Math.max(0, depth + (isEntering ? 1 : -1)) }), - }), - EndedPageDrag: () => ({ - model: modifyFields(model, { dragDepth: () => 0, isDropTarget: () => false }), - }), + GotDropZoneMessage: ({ message: child }) => foldDropZone(model, child), PressedCropHandle: ({ mode, clientX, clientY }) => ({ model: mapReadyImage(model, (image) => ({ ...image, @@ -235,45 +265,67 @@ export const update = (model: Model, message: Message, shared: Shared): Return = }), ReleasedCropPointer: () => ({ model: mapReadyImage(model, (image) => ({ ...image, drag: null })) }), ClickedCancelCrop: () => closeCrop(model), + // A removal in flight finishes first: one avatar write at a time. ClickedSaveCrop: () => { const crop = model.crop - return crop._tag === "Ready" + return crop._tag === "Ready" && model.avatarAction._tag === "Idle" ? { model: modifyFields(model, { - crop: () => ({ _tag: "Processing", image: crop.image }), + crop: () => CropState.Processing({ image: crop.image }), }), commands: [CropAvatarImage({ src: crop.image.src, crop: crop.image.crop })], } : { model } }, - CompletedCropImage: ({ blob }) => { + SucceededCropImage: ({ blob }) => { + const userId = model.userId + if (model.crop._tag !== "Processing") return { model } + if (userId === null) return closeCrop(model) + return Update.combine(model, [ + closeCrop, + (closed: Model) => ({ + model: modifyFields(closed, { avatarAction: () => AvatarAction.Uploading() }), + commands: [UploadAvatar({ userId, blob })], + }), + ]) + }, + FailedCropImage: () => { const crop = model.crop - if (crop._tag !== "Processing") return { model } - if (blob === null) - return { model: modifyFields(model, { crop: () => ({ _tag: "Ready", image: crop.image }) }) } - const closed = closeCrop(model) - if (model.userId === null) return closed - return { - model: modifyFields(closed.model, { isUploading: () => true }), - commands: [...(closed.commands ?? []), UploadAvatar({ userId: model.userId, blob })], - } + return crop._tag === "Processing" + ? { + model: modifyFields(model, { crop: () => CropState.Ready({ image: crop.image }) }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToast("Could not crop the image", "Please try again."), + }), + } + : { model } }, - CompletedUploadAvatar: ({ isUploaded }) => ({ - model: modifyFields(model, { isUploading: () => false }), - outMessage: isUploaded - ? refreshUser("Profile picture updated") - : toast(errorToast("Upload failed", "Failed to update profile picture. Please try again.")), + SucceededUploadAvatar: () => ({ + model: modifyFields(model, { avatarAction: () => AvatarAction.Idle() }), + outMessage: refreshUser("Profile picture updated"), + }), + FailedUploadAvatar: ({ reason }) => ({ + model: modifyFields(model, { avatarAction: () => AvatarAction.Idle() }), + outMessage: PageOutMessage.RequestedToast({ toast: uploadAvatarToast(reason) }), }), ClickedResetAvatar: () => - model.isResetting || model.userId === null + model.avatarAction._tag !== "Idle" || model.userId === null ? { model } - : { model: modifyFields(model, { isResetting: () => true }), commands: [ResetAvatar({ userId: model.userId })] }, - CompletedResetAvatar: ({ isReset }) => ({ - model: modifyFields(model, { isResetting: () => false }), - outMessage: isReset - ? refreshUser("Profile picture removed") - : toast(errorToast("Failed to remove profile picture")), + : { + model: modifyFields(model, { avatarAction: () => AvatarAction.Resetting() }), + commands: [ResetAvatar({ userId: model.userId })], + }, + SucceededResetAvatar: () => ({ + model: modifyFields(model, { avatarAction: () => AvatarAction.Idle() }), + outMessage: refreshUser("Profile picture removed"), + }), + FailedResetAvatar: ({ reason }) => ({ + model: modifyFields(model, { avatarAction: () => AvatarAction.Idle() }), + outMessage: PageOutMessage.RequestedToast({ + toast: updateUserToast("Failed to remove profile picture", reason), + }), }), - GotCropModalMessage: ({ message: child }) => foldCropModal(model, child), + GotCropModalMessage: ({ message: child }) => + Update.combine(model, [foldCropModal(child), closedCropModal(model.cropModal.isOpen)]), GotInteractionMessage: ({ message: child }) => interaction.fold(model, child), }) diff --git a/apps/web-foldkit/src/page/my-settings/profile/view.ts b/apps/web-foldkit/src/page/my-settings/profile/view.ts index 81ef47913..4cf652e60 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/view.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/view.ts @@ -1,4 +1,5 @@ import { Submodel } from "foldkit" +import type * as FieldValidation from "foldkit/fieldValidation" import type { Html, HtmlBuilder } from "foldkit/html" import { IconEnvelope } from "../../../icons" import { button } from "../../../ui/button" @@ -10,7 +11,7 @@ import * as TimezoneSelect from "../../../ui/timezone-select" import type { PageViewInputs, Shared } from "../../contract" import { pageHeader } from "../shared" import { profilePictureUpload } from "./avatar-view" -import { errorsOf, initialsOf, isSaveDisabled } from "./form" +import { initialsOf, isSaveDisabled } from "./form" import { Message } from "./message" import type { Model } from "./model" import { interaction, toTimezoneMessage } from "./update" @@ -26,27 +27,28 @@ const nameField = ( field: { id: string label: string - value: string - error: string | null + state: FieldValidation.Field<string> onInput: (value: string) => Message }, -): Html => - textField( +): Html => { + const error = field.state._tag === "Invalid" ? field.state.errors[0] : undefined + return textField( h, { id: field.id, - value: field.value, + value: field.state.value, onInput: field.onInput, isRequired: true, - isInvalid: field.error !== null, + isInvalid: error !== undefined, interaction: interaction.wiring(model), }, (parts) => [ parts.label([field.label], { className: "lg:hidden" }), parts.input(), - ...(field.error === null ? [] : [parts.fieldError([field.error])]), + ...(error === undefined ? [] : [parts.fieldError([error])]), ], ) +} const section = (h: HtmlBuilder<Message>, title: string, children: ReadonlyArray<Html>, isRequired = false) => h.div( @@ -57,7 +59,6 @@ const section = (h: HtmlBuilder<Message>, title: string, children: ReadonlyArray /** The page body as a plain view (Scene tests render it directly). */ export const profileView = (model: Model, shared: Shared, h: HtmlBuilder<Message>): Html => { const user = shared.currentUser - const errors = errorsOf(model) return h.form( [h.Class("flex flex-col gap-6 px-4 lg:px-8"), h.OnSubmit(Message.SubmittedProfile())], [ @@ -81,15 +82,13 @@ export const profileView = (model: Model, shared: Shared, h: HtmlBuilder<Message nameField(h, model, { id: "profile-first-name", label: "First name", - value: model.values.firstName, - error: errors.firstName, + state: model.firstName, onInput: toFirstName, }), nameField(h, model, { id: "profile-last-name", label: "Last name", - value: model.values.lastName, - error: errors.lastName, + state: model.lastName, onInput: toLastName, }), ], @@ -135,7 +134,7 @@ export const profileView = (model: Model, shared: Shared, h: HtmlBuilder<Message }, attributes: [h.Type("submit")], }, - [model.isSubmitting ? "Saving..." : "Save"], + [model.save._tag === "Saving" ? "Saving..." : "Save"], ), ], ), diff --git a/apps/web-foldkit/src/page/my-settings/user.ts b/apps/web-foldkit/src/page/my-settings/user.ts index 6a1ab5387..2589b8ba4 100644 --- a/apps/web-foldkit/src/page/my-settings/user.ts +++ b/apps/web-foldkit/src/page/my-settings/user.ts @@ -1,6 +1,8 @@ import { User } from "@hazel/domain/models" import type { UserId } from "@hazel/schema" import { Effect, Schema } from "effect" +import { CommonReason, type UserErrorMessage } from "~/lib/error-messages" +import { catchCommon } from "../../data/actions" import { HazelRpc } from "../../rpc" /** The signed-in user's own `users` row, which the notification and profile pages edit. */ @@ -14,6 +16,15 @@ export const UserRow = Schema.Struct({ }) export type UserRow = typeof UserRow.Type +/** Why `user.update` failed: the row is gone (`UserNotFoundError`), or a shared reason. */ +export const UpdateUserFailure = Schema.Union([Schema.Literal("UserNotFound"), CommonReason]) +export type UpdateUserFailure = typeof UpdateUserFailure.Type + +/** The `failureMessage` copy for `UpdateUserFailure`'s own literal. */ +export const updateUserFailureCopy: { readonly UserNotFound: UserErrorMessage } = { + UserNotFound: { title: "Account not found", description: "Please sign in again." }, +} + /** The `user.update` RPC behind the legacy `updateUserAction` (fields left out are not sent). */ export const updateUser = (payload: { readonly id: UserId @@ -22,8 +33,16 @@ export const updateUser = (payload: { readonly avatarUrl?: string | null readonly timezone?: string | null readonly settings?: User.UserSettings | null -}) => - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("user.update", payload) - }) +}) => HazelRpc.use((rpc) => rpc("user.update", payload)) + +/** Settles a `user.update` into `onSuccess`, or its failure into an `UpdateUserFailure`. */ +export const settleUpdateUser = <Success, Failure>( + effect: ReturnType<typeof updateUser>, + onSuccess: Success, + onReason: (reason: UpdateUserFailure) => Failure, +) => + effect.pipe( + Effect.as<Success | Failure>(onSuccess), + Effect.catchTags({ UserNotFoundError: () => Effect.succeed(onReason("UserNotFound")) }), + catchCommon(onReason), + ) From e7fd2527684aa8944ec622b48470340374d73279 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:57:37 +0200 Subject: [PATCH 44/52] fix(web): channel settings Save rebases after saving, share search shows failures Overview and webhook names use the shared name rules with visible errors, integration lists are AsyncData, ProviderCard actions are a union, row deletes use ConfirmAction; the document stub moves to the test setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../page/channel-settings/connect/command.ts | 97 ++--- .../page/channel-settings/connect/model.ts | 21 +- .../channel-settings/connect/scene.test.ts | 49 ++- .../connect/share-modal-view.ts | 55 ++- .../channel-settings/connect/share-modal.ts | 154 +++++--- .../channel-settings/connect/story.test.ts | 114 ++++-- .../page/channel-settings/connect/update.ts | 103 ++++-- .../src/page/channel-settings/connect/view.ts | 52 ++- .../channel-settings/integrations/cards.ts | 93 ++++- .../channel-settings/integrations/command.ts | 350 ++++++++---------- .../channel-settings/integrations/message.ts | 36 +- .../channel-settings/integrations/model.ts | 88 +++-- .../integrations/provider-card.ts | 12 +- .../channel-settings/integrations/rows.ts | 25 +- .../channel-settings/integrations/rss-card.ts | 32 +- .../integrations/scene.test.ts | 49 ++- .../integrations/story.test.ts | 180 +++++---- .../integrations/update-cards.ts | 186 +++++----- .../channel-settings/integrations/update.ts | 266 +++++++------ .../channel-settings/integrations/view.ts | 21 +- .../channel-settings/integrations/webhooks.ts | 56 +-- .../page/channel-settings/overview/message.ts | 4 +- .../page/channel-settings/overview/model.ts | 40 +- .../channel-settings/overview/scene.test.ts | 11 +- .../channel-settings/overview/story.test.ts | 77 ++-- .../channel-settings/overview/subscription.ts | 12 +- .../page/channel-settings/overview/update.ts | 112 +++--- .../page/channel-settings/overview/view.ts | 40 +- .../document-stub.ts | 0 29 files changed, 1386 insertions(+), 949 deletions(-) rename apps/web-foldkit/src/{page/channel-settings => test}/document-stub.ts (100%) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/command.ts b/apps/web-foldkit/src/page/channel-settings/connect/command.ts index af28f1d03..05d3aae9e 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/command.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/command.ts @@ -1,9 +1,9 @@ import { ChannelId, ConnectConversationId, ConnectInviteId, OrganizationId } from "@hazel/schema" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" import { toDate } from "~/lib/utils" +import { catchCommon } from "../../../data/actions" import { HazelRpc } from "../../../rpc" -import { failureToast } from "../../../data/actions" import { type Invite, Message } from "./model" /** `listOutgoingInvitesQuery`, filtered to the invites this channel hosts. */ @@ -11,49 +11,40 @@ export const ListOutgoingInvites = Command.define("ListOutgoingInvites", { args: { organizationId: OrganizationId, channelId: ChannelId }, messages: [Message.SucceededListOutgoingInvites, Message.FailedListOutgoingInvites], execute: ({ organizationId, channelId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const response = yield* client("connectShare.invite.listOutgoing", { organizationId }) - const invites = response.data - .filter((invite) => invite.hostChannelId === channelId) - .map( - (invite): Invite => ({ - id: invite.id, - targetValue: invite.targetValue, - status: invite.status, - createdAtMs: toDate(invite.createdAt).getTime(), - }), - ) - return Message.SucceededListOutgoingInvites({ organizationId, invites }) - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedListOutgoingInvites()))), + HazelRpc.use((rpc) => rpc("connectShare.invite.listOutgoing", { organizationId })).pipe( + Effect.map((response) => + Message.SucceededListOutgoingInvites({ + organizationId, + invites: response.data + .filter((invite) => invite.hostChannelId === channelId) + .map( + (invite): Invite => ({ + id: invite.id, + targetValue: invite.targetValue, + status: invite.status, + createdAtMs: toDate(invite.createdAt).getTime(), + }), + ), + }), + ), + catchCommon((reason) => Message.FailedListOutgoingInvites({ organizationId, reason })), + ), }) export const RevokeInvite = Command.define("RevokeInvite", { args: { inviteId: ConnectInviteId }, messages: [Message.SucceededRevokeInvite, Message.FailedRevokeInvite], execute: ({ inviteId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("connectShare.invite.revoke", { inviteId })) - return Exit.match(exit, { - onSuccess: () => Message.SucceededRevokeInvite({ inviteId }), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast", { - ConnectInviteNotFoundError: { - title: "Invite not found", - description: "This invite may have already been revoked or expired.", - isRetryable: false, - }, - ConnectInviteInvalidStateError: { - title: "Cannot revoke", - description: "This invite is no longer in a revokable state.", - isRetryable: false, - }, - }) - return Message.FailedRevokeInvite({ inviteId, toast }) - }, - }) - }), + HazelRpc.use((rpc) => rpc("connectShare.invite.revoke", { inviteId })).pipe( + Effect.as(Message.SucceededRevokeInvite({ inviteId })), + Effect.catchTags({ + ConnectInviteNotFoundError: () => + Effect.succeed(Message.FailedRevokeInvite({ inviteId, reason: "NotFound" })), + ConnectInviteInvalidStateError: () => + Effect.succeed(Message.FailedRevokeInvite({ inviteId, reason: "NotRevokable" })), + }), + catchCommon((reason) => Message.FailedRevokeInvite({ inviteId, reason })), + ), }) export const DisconnectOrganization = Command.define("DisconnectOrganization", { @@ -65,21 +56,15 @@ export const DisconnectOrganization = Command.define("DisconnectOrganization", { }, messages: [Message.SucceededDisconnect, Message.FailedDisconnect], execute: ({ mountId, conversationId, organizationId, isLeaving }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("connectShare.organization.disconnect", { conversationId, organizationId }), - ) - return Exit.match(exit, { - onSuccess: () => - Message.SucceededDisconnect({ - mountId, - successMessage: isLeaving ? "Left shared channel" : "Organization disconnected", - }), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast") - return Message.FailedDisconnect({ mountId, toast }) - }, - }) - }), + HazelRpc.use((rpc) => + rpc("connectShare.organization.disconnect", { conversationId, organizationId }), + ).pipe( + Effect.as( + Message.SucceededDisconnect({ + mountId, + successMessage: isLeaving ? "Left shared channel" : "Organization disconnected", + }), + ), + catchCommon((reason) => Message.FailedDisconnect({ mountId, reason })), + ), }) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/model.ts b/apps/web-foldkit/src/page/channel-settings/connect/model.ts index 7d07d7375..90ac04edc 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/model.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/model.ts @@ -1,7 +1,8 @@ import { ChannelId, ConnectConversationId, ConnectInviteId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" import * as ShareModal from "./share-modal" @@ -28,6 +29,13 @@ export const Invite = Schema.Struct({ }) export type Invite = typeof Invite.Type +/** The outgoing invites this channel hosts. */ +export const Invites = AsyncData.Schema(Schema.Array(Invite), CommonReason) + +/** What can go wrong revoking an invite, beyond the shared reasons. */ +export const RevokeInviteFailure = Schema.Union([Schema.Literals(["NotFound", "NotRevokable"]), CommonReason]) +export type RevokeInviteFailure = typeof RevokeInviteFailure.Type + export const Model = Schema.Struct({ channelId: ChannelId, channelName: Schema.NullOr(Schema.String), @@ -35,8 +43,8 @@ export const Model = Schema.Struct({ mounts: Schema.Array(Mount), orgs: Schema.Record(Schema.String, OrgSummary), requestedOrganizationId: Schema.NullOr(OrganizationId), - /** Outgoing invites hosted by this channel; empty until the query succeeds. */ - invites: Schema.Array(Invite), + /** Outgoing invites hosted by this channel, for `requestedOrganizationId`. */ + invites: Invites.schema, revokingInviteIds: Schema.Array(ConnectInviteId), disconnectingMountIds: Schema.Array(Schema.String), share: ShareModal.Model, @@ -49,15 +57,16 @@ export const Message = defineMessageUnion({ UpdatedMounts: { mounts: Schema.Array(Mount) }, UpdatedOrgs: { orgs: Schema.Record(Schema.String, OrgSummary) }, SucceededListOutgoingInvites: { organizationId: OrganizationId, invites: Schema.Array(Invite) }, - FailedListOutgoingInvites: {}, + FailedListOutgoingInvites: { organizationId: OrganizationId, reason: CommonReason }, + ClickedRetryInvites: {}, ClickedShareChannel: {}, ClickedRevokeInvite: { inviteId: ConnectInviteId }, GotInteractionMessage: { message: Interaction.Message }, SucceededRevokeInvite: { inviteId: ConnectInviteId }, - FailedRevokeInvite: { inviteId: ConnectInviteId, toast: ToastRequest }, + FailedRevokeInvite: { inviteId: ConnectInviteId, reason: RevokeInviteFailure }, ClickedDisconnect: { mountId: Schema.String }, SucceededDisconnect: { mountId: Schema.String, successMessage: Schema.String }, - FailedDisconnect: { mountId: Schema.String, toast: ToastRequest }, + FailedDisconnect: { mountId: Schema.String, reason: CommonReason }, GotShareModalMessage: { message: ShareModal.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts b/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts index 59e005d58..5c4873c37 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { errorToast, successToast } from "../../../data/actions" +import { successToast } from "../../../data/actions" import { globex, globexGuestMount, @@ -42,6 +42,8 @@ describe("share channel", () => { Scene.scene( config, Scene.given(initial), + Scene.expect(Scene.text("Not shared yet")).toBeAbsent(), + Scene.Subscription.emit(Message.SucceededListOutgoingInvites({ organizationId, invites: [] })), Scene.expect(Scene.text("Not shared yet")).toExist(), Scene.Subscription.emit(Message.UpdatedChannelName({ name: "general" })), Scene.click(Scene.role("button", { name: /Share channel/ })), @@ -86,10 +88,11 @@ describe("share channel", () => { ) }) - test("no matches shows the empty result, and Cancel closes the dialog", () => { + test("a failed search shows the error and Try again; no matches shows the empty result", () => { Scene.scene( config, Scene.given(initial), + Scene.Subscription.emit(Message.SucceededListOutgoingInvites({ organizationId, invites: [] })), Scene.Subscription.emit(Message.UpdatedChannelName({ name: "general" })), Scene.click(Scene.role("button", { name: /Share this channel/ })), portalModalMounted, @@ -100,7 +103,17 @@ describe("share channel", () => { ), Scene.Command.resolve( ShareModal.SearchWorkspaces, - ShareModal.Message.FailedSearchWorkspaces({ version: 1 }), + ShareModal.Message.FailedSearchWorkspaces({ version: 1, reason: "Offline" }), + ), + Scene.expect(Scene.within(dialog, Scene.text("Connection lost"))).toExist(), + Scene.expect(Scene.text("No public workspaces found")).toBeAbsent(), + Scene.click(Scene.within(dialog, Scene.role("button", { name: "Try again" }))), + Scene.Command.expectExact( + ShareModal.SearchWorkspaces({ version: 2, query: "zz", organizationId }), + ), + Scene.Command.resolve( + ShareModal.SearchWorkspaces, + ShareModal.Message.SucceededSearchWorkspaces({ version: 2, results: [] }), ), Scene.expect(Scene.text("No public workspaces found")).toExist(), Scene.click(Scene.within(dialog, Scene.role("button", { name: "Cancel" }))), @@ -123,15 +136,32 @@ describe("invitations", () => { Scene.expect(Scene.role("button", { name: "Revoking..." })).toBeDisabled(), Scene.Command.resolve( RevokeInvite, - Message.FailedRevokeInvite({ - inviteId, - toast: errorToast(failureToastFixture.title, failureToastFixture.description), - }), + Message.FailedRevokeInvite({ inviteId, reason: "Unexpected" }), ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), Scene.expect(Scene.role("button", { name: "Revoke" })).toBeEnabled(), ) }) + + test("a failed load shows the error and Try again instead of the empty state", () => { + Scene.scene( + config, + Scene.given(initial), + Scene.Subscription.emit( + Message.FailedListOutgoingInvites({ organizationId, reason: "Unavailable" }), + ), + Scene.expect(Scene.text("Couldn't load invitations")).toExist(), + Scene.expect(Scene.text("Not shared yet")).toBeAbsent(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(ListOutgoingInvites({ organizationId, channelId })), + Scene.Command.resolve( + ListOutgoingInvites, + Message.SucceededListOutgoingInvites({ organizationId, invites: [pendingInvite] }), + ), + Scene.expect(Scene.text("Couldn't load invitations")).toBeAbsent(), + Scene.expect(Scene.role("button", { name: "Revoke" })).toBeEnabled(), + ) + }) }) describe("active connections", () => { @@ -193,10 +223,7 @@ describe("active connections", () => { Scene.expect(Scene.role("button", { name: "Leaving..." })).toBeDisabled(), Scene.Command.resolve( DisconnectOrganization, - Message.FailedDisconnect({ - mountId: "mount-1", - toast: errorToast(failureToastFixture.title, failureToastFixture.description), - }), + Message.FailedDisconnect({ mountId: "mount-1", reason: "Unexpected" }), ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), ) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts index 07434ac96..19c3f7a37 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts @@ -1,7 +1,9 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" import { dialogTitleBase } from "~/components/ui/dialog.styles" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" import { IconClose } from "../../../icons" import { avatar } from "../../../ui/avatar" import { button } from "../../../ui/button" @@ -43,21 +45,52 @@ const resultRow = (h: HtmlBuilder<Message>, workspace: Workspace): Html => { ) } +const searchMessage = (h: HtmlBuilder<Message>, text: string): Html => + h.div([h.Class("px-4 py-6 text-center text-muted-fg text-sm")], [text]) + +const searchLoading = (h: HtmlBuilder<Message>): Html => + h.div([h.Class("flex items-center justify-center py-6")], [loader(h)]) + +const workspaceList = (h: HtmlBuilder<Message>, workspaces: ReadonlyArray<Workspace>): Html => + workspaces.length === 0 + ? searchMessage(h, "No public workspaces found") + : h.div( + [h.Class("divide-y divide-border")], + workspaces.map((workspace) => resultRow(h, workspace)), + ) + +const searchFailed = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return h.div( + [h.Class("flex flex-col items-center gap-2 px-4 py-6 text-center"), h.Role("alert")], + [ + h.span([h.Class("font-medium text-fg text-sm")], [message.title]), + ...(message.description === undefined + ? [] + : [h.span([h.Class("text-muted-fg text-xs")], [message.description])]), + ...(isRetryableReason(reason) + ? [ + button(h, { intent: "outline", size: "sm", onPress: Message.ClickedRetrySearch() }, [ + "Try again", + ]), + ] + : []), + ], + ) +} + const searchResults = (h: HtmlBuilder<Message>, model: Model): Html => h.div( [h.Class("rounded-lg border border-border bg-bg")], [ - model.isSearching - ? h.div([h.Class("flex items-center justify-center py-6")], [loader(h)]) - : model.searchResults.length === 0 - ? h.div( - [h.Class("px-4 py-6 text-center text-muted-fg text-sm")], - ["No public workspaces found"], - ) - : h.div( - [h.Class("divide-y divide-border")], - model.searchResults.map((workspace) => resultRow(h, workspace)), - ), + AsyncData.match(model.searchResults, { + onIdle: () => searchLoading(h), + onLoading: () => searchLoading(h), + onRefreshing: (workspaces) => workspaceList(h, workspaces), + onFailure: (reason) => searchFailed(h, reason), + onStale: ({ error }) => searchFailed(h, error), + onSuccess: (workspaces) => workspaceList(h, workspaces), + }), ], ) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts b/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts index 51b1fb2ec..3f9d40ef3 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts @@ -1,12 +1,13 @@ import { ChannelId, OrganizationId } from "@hazel/schema" -import { Duration, Effect, Exit, Schema } from "effect" +import { Duration, Effect, Schema } from "effect" import { Command, type Update } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" -import { ToastRequest } from "../../../overlay/toasts" +import { CommonReason, failureMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../../data/actions" import { HazelRpc } from "../../../rpc" import * as Modal from "../../../ui/modal" -import { failureToast, successToast } from "../../../data/actions" import { PageOutMessage } from "../../out-message" /** Port of `components/connect/share-channel-modal.tsx`. */ @@ -21,12 +22,21 @@ export const Workspace = Schema.Struct({ }) export type Workspace = typeof Workspace.Type +/** The workspace search: `Idle` below two characters or once a workspace is picked. */ +export const SearchResults = AsyncData.Schema(Schema.Array(Workspace), CommonReason) + +/** What can go wrong sending the invite, beyond the shared reasons. */ +export const CreateInviteFailure = Schema.Union([ + Schema.Literals(["WorkspaceNotFound", "AlreadyShared"]), + CommonReason, +]) +export type CreateInviteFailure = typeof CreateInviteFailure.Type + export const Model = Schema.Struct({ modal: Modal.Model, searchQuery: Schema.String, - searchResults: Schema.Array(Workspace), - isSearching: Schema.Boolean, - /** Bumped on every keystroke; only the latest debounced search counts (`clearTimeout`). */ + searchResults: SearchResults.schema, + /** Bumped on every keystroke and retry; only the latest search counts (`clearTimeout`). */ searchVersion: Schema.Number, selectedWorkspace: Schema.NullOr(Workspace), allowGuestMemberAdds: Schema.Boolean, @@ -37,8 +47,7 @@ export type Model = typeof Model.Type export const init = (): Model => ({ modal: Modal.init("share-channel"), searchQuery: "", - searchResults: [], - isSearching: false, + searchResults: SearchResults.Idle(), searchVersion: 0, selectedWorkspace: null, allowGuestMemberAdds: false, @@ -51,14 +60,15 @@ export const Message = defineMessageUnion({ ChangedWorkspaceQuery: { value: Schema.String }, ElapsedSearchDelay: { version: Schema.Number, query: Schema.String }, SucceededSearchWorkspaces: { version: Schema.Number, results: Schema.Array(Workspace) }, - FailedSearchWorkspaces: { version: Schema.Number }, + FailedSearchWorkspaces: { version: Schema.Number, reason: CommonReason }, + ClickedRetrySearch: {}, ClickedWorkspace: { workspace: Workspace }, ClickedClearWorkspace: {}, ToggledAllowGuestMemberAdds: { isSelected: Schema.Boolean }, ClickedCancel: {}, ClickedSendInvite: {}, SucceededCreateInvite: {}, - FailedCreateInvite: { toast: ToastRequest }, + FailedCreateInvite: { reason: CreateInviteFailure }, GotModalMessage: { message: Modal.Message }, }) export type Message = typeof Message.Type @@ -76,17 +86,20 @@ export const SearchWorkspaces = Command.define("SearchWorkspaces", { args: { version: Schema.Number, query: Schema.String, organizationId: OrganizationId }, messages: [Message.SucceededSearchWorkspaces, Message.FailedSearchWorkspaces], execute: ({ version, query, organizationId }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const response = yield* client("connectShare.workspace.search", { query, organizationId }) - const results = response.data.map((result) => ({ - id: result.id, - name: result.name, - slug: result.slug, - logoUrl: result.logoUrl, - })) - return Message.SucceededSearchWorkspaces({ version, results }) - }).pipe(Effect.catch(() => Effect.succeed(Message.FailedSearchWorkspaces({ version })))), + HazelRpc.use((rpc) => rpc("connectShare.workspace.search", { query, organizationId })).pipe( + Effect.map((response) => + Message.SucceededSearchWorkspaces({ + version, + results: response.data.map((result) => ({ + id: result.id, + name: result.name, + slug: result.slug, + logoUrl: result.logoUrl, + })), + }), + ), + catchCommon((reason) => Message.FailedSearchWorkspaces({ version, reason })), + ), }) export const CreateInvite = Command.define("CreateInvite", { @@ -98,35 +111,23 @@ export const CreateInvite = Command.define("CreateInvite", { }, messages: [Message.SucceededCreateInvite, Message.FailedCreateInvite], execute: ({ channelId, guestOrganizationId, slug, allowGuestMemberAdds }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("connectShare.invite.create", { - channelId, - guestOrganizationId, - target: { kind: "slug", value: slug }, - allowGuestMemberAdds, - }), - ) - return Exit.match(exit, { - onSuccess: () => Message.SucceededCreateInvite(), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast", { - ConnectWorkspaceNotFoundError: { - title: "Workspace not found", - description: "No workspace matches that name or slug.", - isRetryable: false, - }, - ConnectChannelAlreadySharedError: { - title: "Already shared", - description: "This channel is already shared with that organization.", - isRetryable: false, - }, - }) - return Message.FailedCreateInvite({ toast }) - }, - }) - }), + HazelRpc.use((rpc) => + rpc("connectShare.invite.create", { + channelId, + guestOrganizationId, + target: { kind: "slug", value: slug }, + allowGuestMemberAdds, + }), + ).pipe( + Effect.as(Message.SucceededCreateInvite()), + Effect.catchTags({ + ConnectWorkspaceNotFoundError: () => + Effect.succeed(Message.FailedCreateInvite({ reason: "WorkspaceNotFound" })), + ConnectChannelAlreadySharedError: () => + Effect.succeed(Message.FailedCreateInvite({ reason: "AlreadyShared" })), + }), + catchCommon((reason) => Message.FailedCreateInvite({ reason })), + ), }) // UPDATE @@ -140,6 +141,18 @@ const closed = (model: Model): Model => ({ ...init(), modal: Modal.close(model.m export const open = (model: Model): Model => modifyFields(model, { modal: (modal) => Modal.open(modal).model }) +/** Starts a search for the current query now, superseding any search in flight. */ +const search = (model: Model, query: string, organizationId: OrganizationId): Return => { + const version = model.searchVersion + 1 + return { + model: modifyFields(model, { + searchVersion: () => version, + searchResults: () => SearchResults.Loading(), + }), + commands: [SearchWorkspaces({ version, query, organizationId })], + } +} + export const update = (model: Model, message: Message, context: Context): Return => Message.match<Return>(message, { ChangedWorkspaceQuery: ({ value }) => { @@ -150,9 +163,9 @@ export const update = (model: Model, message: Message, context: Context): Return searchVersion: () => version, }) if (value.length < 2) - return { model: modifyFields(base, { searchResults: () => [], isSearching: () => false }) } + return { model: modifyFields(base, { searchResults: () => SearchResults.Idle() }) } return { - model: modifyFields(base, { isSearching: () => true }), + model: modifyFields(base, { searchResults: () => SearchResults.Loading() }), commands: [WaitForSearchDelay({ version, query: value })], } }, @@ -168,18 +181,30 @@ export const update = (model: Model, message: Message, context: Context): Return SucceededSearchWorkspaces: ({ version, results }) => version !== model.searchVersion ? { model } - : { model: modifyFields(model, { searchResults: () => results, isSearching: () => false }) }, - FailedSearchWorkspaces: ({ version }) => + : { + model: modifyFields(model, { + searchResults: () => SearchResults.Success({ data: results }), + }), + }, + FailedSearchWorkspaces: ({ version, reason }) => version !== model.searchVersion ? { model } - : { model: modifyFields(model, { searchResults: () => [], isSearching: () => false }) }, + : { + model: modifyFields(model, { + searchResults: () => SearchResults.Failure({ error: reason }), + }), + }, + ClickedRetrySearch: () => + AsyncData.isFailure(model.searchResults) && context.organizationId !== null + ? search(model, model.searchQuery, context.organizationId) + : { model }, ClickedWorkspace: ({ workspace }) => workspace.slug ? { model: modifyFields(model, { selectedWorkspace: () => workspace, searchQuery: () => "", - searchResults: () => [], + searchResults: () => SearchResults.Idle(), }), } : { model }, @@ -207,9 +232,22 @@ export const update = (model: Model, message: Message, context: Context): Return model: closed(model), outMessage: PageOutMessage.RequestedToast({ toast: successToast("Invite sent") }), }), - FailedCreateInvite: ({ toast }) => ({ + FailedCreateInvite: ({ reason }) => ({ model: modifyFields(model, { isSubmitting: () => false }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + WorkspaceNotFound: { + title: "Workspace not found", + description: "No workspace matches that name or slug.", + }, + AlreadyShared: { + title: "Already shared", + description: "This channel is already shared with that organization.", + }, + }), + ), + }), }), GotModalMessage: ({ message: modalMessage }) => { const next = Modal.update(model.modal, modalMessage) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/story.test.ts b/apps/web-foldkit/src/page/channel-settings/connect/story.test.ts index 19346465a..86abdf4ef 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/story.test.ts @@ -1,9 +1,8 @@ -import "../document-stub" +import "../../../test/document-stub" import { ChannelId, ConnectInviteId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { errorToast } from "../../../data/actions" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import { @@ -15,7 +14,7 @@ import { slugless, } from "../../../test/pages-channel-settings-fixtures" import { DisconnectOrganization, ListOutgoingInvites, RevokeInvite } from "./command" -import { Message } from "./model" +import { Invites, Message } from "./model" import * as ShareModal from "./share-modal" import { init, sharedChanged, update } from "./update" import { sharedDefaults } from "../../test-shared" @@ -57,7 +56,7 @@ describe("channel connect", () => { ListOutgoingInvites, Message.SucceededListOutgoingInvites({ organizationId, invites: [] }), ), - model((current) => expect(current.invites).toEqual([])), + model((current) => expect(current.invites).toEqual(Invites.Success({ data: [] }))), ) }) @@ -66,7 +65,7 @@ describe("channel connect", () => { ShareModal.Message.ChangedWorkspaceQuery({ value: "gl" }), ShareModal.Message.ChangedWorkspaceQuery({ value: "glo" }), ].reduce((current, next) => run(current, share(next)).model, initial.model) - expect(typed.share.isSearching).toBe(true) + expect(typed.share.searchResults).toEqual(ShareModal.SearchResults.Loading()) const stale = run(typed, share(ShareModal.Message.ElapsedSearchDelay({ version: 1, query: "gl" }))) expect(stale.commands ?? []).toHaveLength(0) const latest = run(typed, share(ShareModal.Message.ElapsedSearchDelay({ version: 2, query: "glo" }))) @@ -83,10 +82,7 @@ describe("revoke failure", () => { message(Message.SucceededListOutgoingInvites({ organizationId, invites: [invite] })), message(Message.ClickedRevokeInvite({ inviteId })), Command.expectExact(RevokeInvite({ inviteId })), - Command.resolve( - RevokeInvite, - Message.FailedRevokeInvite({ inviteId, toast: errorToast("Invite not found", description) }), - ), + Command.resolve(RevokeInvite, Message.FailedRevokeInvite({ inviteId, reason: "NotFound" })), expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "error", title: "Invite not found", description }, @@ -154,14 +150,15 @@ describe("disconnect", () => { ), Command.resolve( DisconnectOrganization, - Message.FailedDisconnect({ - mountId: hostMount.id, - toast: errorToast("Something went wrong"), - }), + Message.FailedDisconnect({ mountId: hostMount.id, reason: "Unexpected" }), ), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Something went wrong", description: null }, + toast: { + intent: "error", + title: "Something went wrong", + description: "Please try again.", + }, }), ), model((current) => expect(current.disconnectingMountIds).toEqual([])), @@ -210,7 +207,32 @@ describe("invites list", () => { run, given(initial.model), message(Message.SucceededListOutgoingInvites({ organizationId: otherOrg, invites: [invite] })), - model((current) => expect(current.invites).toEqual([])), + model((current) => expect(current.invites).toEqual(Invites.Loading())), + ) + }) + + test("a failed load is a Failure, and a failed refetch keeps the list (Stale)", () => { + story( + run, + given(initial.model), + message(Message.FailedListOutgoingInvites({ organizationId, reason: "Offline" })), + model((current) => expect(current.invites).toEqual(Invites.Failure({ error: "Offline" }))), + message(Message.ClickedRetryInvites()), + Command.expectExact(ListOutgoingInvites({ organizationId, channelId })), + model((current) => expect(current.invites).toEqual(Invites.Loading())), + Command.resolve( + ListOutgoingInvites, + Message.SucceededListOutgoingInvites({ organizationId, invites: [invite] }), + ), + message(Message.ClickedRetryInvites()), + model((current) => expect(current.invites).toEqual(Invites.Refreshing({ data: [invite] }))), + Command.resolve( + ListOutgoingInvites, + Message.FailedListOutgoingInvites({ organizationId, reason: "Timeout" }), + ), + model((current) => + expect(current.invites).toEqual(Invites.Stale({ error: "Timeout", data: [invite] })), + ), ) }) @@ -258,7 +280,7 @@ describe("share modal", () => { ListOutgoingInvites, Message.SucceededListOutgoingInvites({ organizationId, invites: [invite] }), ), - model((current) => expect(current.invites).toEqual([invite])), + model((current) => expect(current.invites).toEqual(Invites.Success({ data: [invite] }))), ) }) @@ -269,11 +291,15 @@ describe("share modal", () => { message(share(ShareModal.Message.ClickedSendInvite())), Command.resolve( ShareModal.CreateInvite, - ShareModal.Message.FailedCreateInvite({ toast: errorToast("Already shared", "Shared.") }), + ShareModal.Message.FailedCreateInvite({ reason: "AlreadyShared" }), ), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Already shared", description: "Shared." }, + toast: { + intent: "error", + title: "Already shared", + description: "This channel is already shared with that organization.", + }, }), ), model((current) => { @@ -315,7 +341,7 @@ describe("share modal", () => { given(opened), message(share(ShareModal.Message.ChangedWorkspaceQuery({ value: "g" }))), Command.expectNone(), - model((current) => expect(current.share.isSearching).toBe(false)), + model((current) => expect(current.share.searchResults).toEqual(ShareModal.SearchResults.Idle())), ) }) @@ -334,12 +360,56 @@ describe("share modal", () => { ShareModal.Message.SucceededSearchWorkspaces({ version: 1, results: [globex] }), ), model((current) => { - expect(current.share.searchResults).toEqual([globex]) - expect(current.share.isSearching).toBe(false) + expect(current.share.searchResults).toEqual( + ShareModal.SearchResults.Success({ data: [globex] }), + ) }), message(share(ShareModal.Message.SucceededSearchWorkspaces({ version: 0, results: [] }))), - model((current) => expect(current.share.searchResults).toEqual([globex])), + model((current) => + expect(current.share.searchResults).toEqual( + ShareModal.SearchResults.Success({ data: [globex] }), + ), + ), + ) + }) + + test("a failed search is a Failure with its reason, not an empty result; Try again searches now", () => { + story( + run, + given(opened), + message(share(ShareModal.Message.ChangedWorkspaceQuery({ value: "glo" }))), + Command.resolve( + ShareModal.WaitForSearchDelay, + ShareModal.Message.ElapsedSearchDelay({ version: 1, query: "glo" }), + ), + Command.resolve( + ShareModal.SearchWorkspaces, + ShareModal.Message.FailedSearchWorkspaces({ version: 1, reason: "Offline" }), + ), + model((current) => + expect(current.share.searchResults).toEqual( + ShareModal.SearchResults.Failure({ error: "Offline" }), + ), + ), + message(share(ShareModal.Message.ClickedRetrySearch())), + Command.expectExact(ShareModal.SearchWorkspaces({ version: 2, query: "glo", organizationId })), + model((current) => + expect(current.share.searchResults).toEqual(ShareModal.SearchResults.Loading()), + ), + Command.resolve( + ShareModal.SearchWorkspaces, + ShareModal.Message.SucceededSearchWorkspaces({ version: 2, results: [globex] }), + ), + ) + }) + + test("a failure for a superseded search is dropped", () => { + const typed = run(opened, share(ShareModal.Message.ChangedWorkspaceQuery({ value: "glo" }))).model + const next = run( + typed, + share(ShareModal.Message.FailedSearchWorkspaces({ version: 0, reason: "Offline" })), ) + expect(next.model.share.searchResults).toEqual(ShareModal.SearchResults.Loading()) }) test("closing the modal resets the draft", () => { diff --git a/apps/web-foldkit/src/page/channel-settings/connect/update.ts b/apps/web-foldkit/src/page/channel-settings/connect/update.ts index 32b172c48..84a21e171 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/update.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/update.ts @@ -1,19 +1,22 @@ +import { Option } from "effect" import { Command } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf, reasonToast, successToast } from "../../../data/actions" import type { RouteOf } from "../../../route" import * as Interaction from "../../../ui/aria/interaction" -import { successToast } from "../../../data/actions" -import { embedInteraction } from "../../settings/integrations/shared/interaction" -import type { PageReturn, Shared } from "../../contract" +import type { PageReturn, PageSubscriptionInput, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import { DisconnectOrganization, ListOutgoingInvites, RevokeInvite } from "./command" -import { Message, type Model, rowRoles } from "./model" +import { Invites, Message, type Model, rowRoles } from "./model" import * as ShareModal from "./share-modal" type Return = PageReturn<Model, Message> -export const interaction = embedInteraction<Model, Message>((message) => - Message.GotInteractionMessage({ message }), +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, ) /** The interaction target of an invite's Revoke button. */ @@ -23,7 +26,10 @@ const requestInvites = (model: Model, shared: Shared): Return => { const organizationId = shared.organization?.id ?? null if (organizationId === null || organizationId === model.requestedOrganizationId) return { model } return { - model: modifyFields(model, { requestedOrganizationId: () => organizationId, invites: () => [] }), + model: modifyFields(model, { + requestedOrganizationId: () => organizationId, + invites: () => Invites.Loading(), + }), commands: [ListOutgoingInvites({ organizationId, channelId: model.channelId })], } } @@ -36,7 +42,7 @@ export const init = (route: RouteOf<"ChannelSettingsConnect">, shared: Shared): mounts: [], orgs: {}, requestedOrganizationId: null, - invites: [], + invites: Invites.Idle(), revokingInviteIds: [], disconnectingMountIds: [], share: ShareModal.init(), @@ -47,11 +53,20 @@ export const init = (route: RouteOf<"ChannelSettingsConnect">, shared: Shared): export const sharedChanged = (model: Model, shared: Shared): Return => requestInvites(model, shared) -/** The reactivity key `connectInvites:outgoing:<org>` refetches the list after a write. */ -const refetchInvites = (model: Model) => - model.requestedOrganizationId === null - ? [] - : [ListOutgoingInvites({ organizationId: model.requestedOrganizationId, channelId: model.channelId })] +/** + * Refetches the list, keeping what it shows (`Refreshing`): after a write (the reactivity key + * `connectInvites:outgoing:<org>`) and on retry. A load in flight may predate the write, so it refetches anyway. + */ +const reloadInvites = (model: Model): Return => { + const organizationId = model.requestedOrganizationId + if (organizationId === null) return { model } + return { + model: modifyFields(model, { + invites: (invites) => Option.getOrElse(AsyncData.revalidateOrLoad(invites), () => invites), + }), + commands: [ListOutgoingInvites({ organizationId, channelId: model.channelId })], + } +} const without = <Id extends string>(ids: ReadonlyArray<Id>, id: Id): ReadonlyArray<Id> => ids.filter((candidate) => candidate !== id) @@ -63,9 +78,22 @@ export const update = (model: Model, message: Message, shared: Shared): Return = UpdatedOrgs: ({ orgs }) => ({ model: modifyFields(model, { orgs: () => orgs }) }), SucceededListOutgoingInvites: ({ organizationId, invites }) => organizationId === model.requestedOrganizationId - ? { model: modifyFields(model, { invites: () => invites }) } + ? { model: modifyFields(model, { invites: () => Invites.Success({ data: invites }) }) } + : { model }, + FailedListOutgoingInvites: ({ organizationId, reason }) => + organizationId === model.requestedOrganizationId + ? { + // A failed refetch keeps the list it had (`Stale`). + model: modifyFields(model, { + invites: (invites) => + Option.match(AsyncData.getData(invites), { + onNone: () => Invites.Failure({ error: reason }), + onSome: (data) => Invites.Stale({ error: reason, data }), + }), + }), + } : { model }, - FailedListOutgoingInvites: () => ({ model }), + ClickedRetryInvites: () => reloadInvites(model), ClickedShareChannel: () => ({ model: modifyFields(model, { share: ShareModal.open }) }), ClickedRevokeInvite: ({ inviteId }) => model.revokingInviteIds.includes(inviteId) @@ -78,14 +106,31 @@ export const update = (model: Model, message: Message, shared: Shared): Return = }), commands: [RevokeInvite({ inviteId })], }, - SucceededRevokeInvite: ({ inviteId }) => ({ - model: modifyFields(model, { revokingInviteIds: (ids) => without(ids, inviteId) }), - commands: refetchInvites(model), - outMessage: PageOutMessage.RequestedToast({ toast: successToast("Invite revoked") }), - }), - FailedRevokeInvite: ({ inviteId, toast }) => ({ + SucceededRevokeInvite: ({ inviteId }) => { + const reloaded = reloadInvites( + modifyFields(model, { revokingInviteIds: (ids) => without(ids, inviteId) }), + ) + return { + ...reloaded, + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Invite revoked") }), + } + }, + FailedRevokeInvite: ({ inviteId, reason }) => ({ model: modifyFields(model, { revokingInviteIds: (ids) => without(ids, inviteId) }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Invite not found", + description: "This invite may have already been revoked or expired.", + }, + NotRevokable: { + title: "Cannot revoke", + description: "This invite is no longer in a revokable state.", + }, + }), + ), + }), }), GotInteractionMessage: ({ message }) => interaction.fold(model, message), ClickedDisconnect: ({ mountId }) => { @@ -114,23 +159,27 @@ export const update = (model: Model, message: Message, shared: Shared): Return = model: modifyFields(model, { disconnectingMountIds: (ids) => without(ids, mountId) }), outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), }), - FailedDisconnect: ({ mountId, toast }) => ({ + FailedDisconnect: ({ mountId, reason }) => ({ model: modifyFields(model, { disconnectingMountIds: (ids) => without(ids, mountId) }), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ toast: reasonToast(reason) }), }), GotShareModalMessage: ({ message: shareMessage }) => { const next = ShareModal.update(model.share, shareMessage, { channelId: model.channelId, organizationId: shared.organization?.id ?? null, }) - const sentInvite = shareMessage._tag === "SucceededCreateInvite" + const withShare = modifyFields(model, { share: () => next.model }) + const reloaded = + shareMessage._tag === "SucceededCreateInvite" + ? reloadInvites(withShare) + : { model: withShare } return { - model: modifyFields(model, { share: () => next.model }), + model: reloaded.model, commands: [ ...Command.mapMessages(next.commands ?? [], (child) => Message.GotShareModalMessage({ message: child }), ), - ...(sentInvite ? refetchInvites(model) : []), + ...(reloaded.commands ?? []), ], outMessage: next.outMessage, } diff --git a/apps/web-foldkit/src/page/channel-settings/connect/view.ts b/apps/web-foldkit/src/page/channel-settings/connect/view.ts index a9b3239c0..f526f1ec1 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/view.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/view.ts @@ -1,6 +1,8 @@ import { Submodel } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" import { getConnectInviteStatusBadge } from "~/lib/connect-shared-channels" +import { type CommonReason, commonMessage, isRetryableReason } from "~/lib/error-messages" import { IconConnect, IconPlus } from "../../../icons" import { avatar } from "../../../ui/avatar" import { badge } from "../../../ui/badge" @@ -155,7 +157,7 @@ const inviteRow = (h: HtmlBuilder<Message>, model: Model, invite: Invite): Html ) } -const invitations = (h: HtmlBuilder<Message>, model: Model): Html => +const invitations = (h: HtmlBuilder<Message>, model: Model, invites: ReadonlyArray<Invite>): Html => h.div( [h.Class("overflow-hidden rounded-xl border border-border bg-bg shadow-sm")], [ @@ -185,7 +187,7 @@ const invitations = (h: HtmlBuilder<Message>, model: Model): Html => ), h.tbody( [h.Class("divide-y divide-border")], - model.invites.map((invite) => inviteRow(h, model, invite)), + invites.map((invite) => inviteRow(h, model, invite)), ), ], ), @@ -194,6 +196,50 @@ const invitations = (h: HtmlBuilder<Message>, model: Model): Html => ], ) +const invitesFailed = (h: HtmlBuilder<Message>, reason: CommonReason): Html => { + const message = commonMessage(reason) + return h.div( + [ + h.Class( + "flex items-center justify-between gap-4 rounded-xl border border-border bg-bg px-4 py-4 md:px-6", + ), + h.Role("alert"), + ], + [ + h.div( + [h.Class("flex flex-col")], + [ + h.span([h.Class("font-medium text-fg text-sm")], ["Couldn't load invitations"]), + h.span([h.Class("text-muted-fg text-xs")], [message.description ?? message.title]), + ], + ), + ...(isRetryableReason(reason) + ? [ + button(h, { intent: "outline", size: "sm", onPress: Message.ClickedRetryInvites() }, [ + "Try again", + ]), + ] + : []), + ], + ) +} + +/** The invitations table, a load failure, or (with no connections either) the "not shared" empty state. */ +const invitationsSection = ( + h: HtmlBuilder<Message>, + model: Model, + isConnected: boolean, +): ReadonlyArray<Html> => { + const invites = AsyncData.getOrElse(model.invites, () => []) + return AsyncData.isFailure(model.invites) + ? [invitesFailed(h, model.invites.error)] + : invites.length > 0 + ? [invitations(h, model, invites)] + : isConnected || AsyncData.isPending(model.invites) + ? [] + : [notShared(h)] +} + const notShared = (h: HtmlBuilder<Message>): Html => emptyState(h, { icon: (className) => IconConnect(h, { className }), @@ -225,7 +271,7 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, ], ), ...(isConnected ? [activeConnections(h, model, shared)] : []), - ...(model.invites.length > 0 ? [invitations(h, model)] : isConnected ? [] : [notShared(h)]), + ...invitationsSection(h, model, isConnected), // `{channel && <ShareChannelModal />}`; its overlay portals to the body. ...(model.channelName === null ? [] diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts b/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts index ced8955fc..4651b99d9 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/cards.ts @@ -1,11 +1,21 @@ +import * as AsyncData from "foldkit/asyncData" import type { Html, HtmlBuilder } from "foldkit/html" +import { isRetryableReason } from "~/lib/error-messages" import { IconPlus } from "../../../icons" import type { ResolvedTheme } from "../../../theme" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" import { providerLogoUrl } from "./command" import { Message } from "./message" -import { type GitHubRepo, type Model, rowMenuId } from "./model" +import { + type ChannelRequestFailure, + channelRequestMessage, + type GitHubRepo, + itemsOf, + type Model, + type RowKind, + rowMenuId, +} from "./model" import { rowMenu } from "./row-menu" /** Ports of `github-integration-card.tsx`, and `github-subscription-card.tsx`. */ @@ -25,6 +35,59 @@ export const emptyList = (h: HtmlBuilder<Message>, title: string, hint: string): ], ) +/** A failed list load: the failure's copy, with Try again for a transient failure. */ +const listFailed = (h: HtmlBuilder<Message>, kind: RowKind, reason: ChannelRequestFailure): Html => { + const message = channelRequestMessage(reason) + const isRetryable = reason !== "ChannelNotFound" && isRetryableReason(reason) + return h.div( + [ + h.Class("flex flex-col items-center gap-2 rounded-lg border border-border py-6 text-center"), + h.Role("alert"), + ], + [ + h.p([h.Class("font-medium text-fg text-sm")], [message.title]), + ...(message.description === undefined + ? [] + : [h.p([h.Class("text-muted-fg text-xs")], [message.description])]), + ...(isRetryable + ? [ + button( + h, + { intent: "outline", size: "sm", onPress: Message.ClickedRetryList({ kind }) }, + ["Try again"], + ), + ] + : []), + ], + ) +} + +/** + * A list's body: the spinner on first load, the failure with a retry, the empty state, or the rows. A reload + * keeps the rows on screen; a failed reload keeps them too, under the failure. + */ +export const listBody = <A>( + h: HtmlBuilder<Message>, + kind: RowKind, + data: AsyncData.AsyncData<ReadonlyArray<A>, ChannelRequestFailure>, + parts: Readonly<{ + loading: () => Html + empty: () => Html + rows: (items: ReadonlyArray<A>) => Html + }>, +): Html => { + const loaded = (items: ReadonlyArray<A>) => (items.length === 0 ? parts.empty() : parts.rows(items)) + return AsyncData.match(data, { + onIdle: parts.loading, + onLoading: parts.loading, + onRefreshing: loaded, + onFailure: (reason) => listFailed(h, kind, reason), + onStale: ({ error, data: items }) => + h.div([h.Class("flex flex-col gap-3")], [listFailed(h, kind, error), loaded(items)]), + onSuccess: loaded, + }) +} + export const cardTitle = ( h: HtmlBuilder<Message>, name: string, @@ -152,7 +215,7 @@ export const gitHubCard = (h: HtmlBuilder<Message>, model: Model, resolved: Reso ), ], ) - const repos = model.github.items + const repos = itemsOf(model.github) return h.div( [h.Class("rounded-xl border border-border bg-bg")], [ @@ -182,18 +245,20 @@ export const gitHubCard = (h: HtmlBuilder<Message>, model: Model, resolved: Reso h.div( [h.Class("p-4")], [ - model.github.isLoading - ? spinner(h) - : repos.length === 0 - ? emptyList( - h, - "No repositories subscribed", - "Add a repository to receive events in this channel", - ) - : h.div( - [h.Class("flex flex-col gap-2")], - repos.map((repo) => gitHubItem(h, model, repo, logoSrc)), - ), + listBody(h, "github", model.github.data, { + loading: () => spinner(h), + empty: () => + emptyList( + h, + "No repositories subscribed", + "Add a repository to receive events in this channel", + ), + rows: (items) => + h.div( + [h.Class("flex flex-col gap-2")], + items.map((repo) => gitHubItem(h, model, repo, logoSrc)), + ), + }), ], ), ], diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/command.ts b/apps/web-foldkit/src/page/channel-settings/integrations/command.ts index 438ac3da2..376eb98ca 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/command.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/command.ts @@ -1,21 +1,12 @@ -import type { ChannelNotFoundError } from "@hazel/domain/rpc" import { ChannelId, ChannelWebhookId, GitHubSubscriptionId, RssSubscriptionId } from "@hazel/schema" -import { Duration, Effect, Exit, Schema } from "effect" +import { Duration, Effect, Schema } from "effect" import { Command } from "foldkit" import { toDate } from "~/lib/utils" +import { catchCommon } from "../../../data/actions" import { HazelRpc } from "../../../rpc" -import { type ErrorHandlers, type ErrorMessage, failureToast } from "../../../data/actions" import { Message } from "./message" import { INTEGRATION_CONFIG, Provider, RowKind } from "./model" -const channelNotFound: ErrorHandlers<ChannelNotFoundError> = { - ChannelNotFoundError: { - title: "Channel not found", - description: "This channel may have been deleted.", - isRetryable: false, - }, -} - const msOf = (date: Parameters<typeof toDate>[0] | null) => (date ? toDate(date).getTime() : null) // LISTS (`listChannelWebhooksMutation`, `listRssSubscriptionsMutation`, `listGitHubSubscriptionsMutation`) @@ -24,121 +15,99 @@ export const ListWebhooks = Command.define("ListWebhooks", { args: { channelId: ChannelId, version: Schema.Number }, messages: [Message.SucceededListWebhooks, Message.FailedList], execute: ({ channelId, version }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("channelWebhook.list", { channelId })) - return Exit.match(exit, { - onSuccess: (response) => - Message.SucceededListWebhooks({ - version, - webhooks: response.data.map((webhook) => ({ - id: webhook.id, - name: webhook.name, - avatarUrl: webhook.avatarUrl ?? null, - tokenSuffix: webhook.tokenSuffix, - isEnabled: webhook.isEnabled, - lastUsedAtMs: msOf(webhook.lastUsedAt), - })), - }), - onFailure: (cause) => - Message.FailedList({ - list: "webhooks", - version, - toast: failureToast(cause, "exitToast", channelNotFound), - }), - }) - }), + HazelRpc.use((rpc) => rpc("channelWebhook.list", { channelId })).pipe( + Effect.map((response) => + Message.SucceededListWebhooks({ + version, + webhooks: response.data.map((webhook) => ({ + id: webhook.id, + name: webhook.name, + avatarUrl: webhook.avatarUrl ?? null, + tokenSuffix: webhook.tokenSuffix, + isEnabled: webhook.isEnabled, + lastUsedAtMs: msOf(webhook.lastUsedAt), + })), + }), + ), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed( + Message.FailedList({ kind: "webhook", version, reason: "ChannelNotFound" }), + ), + }), + catchCommon((reason) => Message.FailedList({ kind: "webhook", version, reason })), + ), }) export const ListRss = Command.define("ListRss", { args: { channelId: ChannelId, version: Schema.Number }, messages: [Message.SucceededListRss, Message.FailedList], execute: ({ channelId, version }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("rssSubscription.list", { channelId })) - return Exit.match(exit, { - onSuccess: (response) => - Message.SucceededListRss({ - version, - feeds: response.data.map((feed) => ({ - id: feed.id, - feedUrl: feed.feedUrl, - feedTitle: feed.feedTitle, - feedIconUrl: feed.feedIconUrl, - consecutiveErrors: feed.consecutiveErrors, - isEnabled: feed.isEnabled, - pollingIntervalMinutes: feed.pollingIntervalMinutes, - })), - }), - onFailure: (cause) => - Message.FailedList({ - list: "rss", - version, - toast: failureToast(cause, "exitToast", channelNotFound), - }), - }) - }), + HazelRpc.use((rpc) => rpc("rssSubscription.list", { channelId })).pipe( + Effect.map((response) => + Message.SucceededListRss({ + version, + feeds: response.data.map((feed) => ({ + id: feed.id, + feedUrl: feed.feedUrl, + feedTitle: feed.feedTitle, + feedIconUrl: feed.feedIconUrl, + consecutiveErrors: feed.consecutiveErrors, + isEnabled: feed.isEnabled, + pollingIntervalMinutes: feed.pollingIntervalMinutes, + })), + }), + ), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed(Message.FailedList({ kind: "rss", version, reason: "ChannelNotFound" })), + }), + catchCommon((reason) => Message.FailedList({ kind: "rss", version, reason })), + ), }) export const ListGitHub = Command.define("ListGitHub", { args: { channelId: ChannelId, version: Schema.Number }, messages: [Message.SucceededListGitHub, Message.FailedList], execute: ({ channelId, version }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit(client("githubSubscription.list", { channelId })) - return Exit.match(exit, { - onSuccess: (response) => - Message.SucceededListGitHub({ - version, - repos: response.data.map((repo) => ({ - id: repo.id, - repositoryFullName: repo.repositoryFullName, - enabledEvents: [...repo.enabledEvents], - branchFilter: repo.branchFilter, - isEnabled: repo.isEnabled, - })), - }), - onFailure: (cause) => - Message.FailedList({ - list: "github", - version, - toast: failureToast(cause, "exitToast", channelNotFound), - }), - }) - }), + HazelRpc.use((rpc) => rpc("githubSubscription.list", { channelId })).pipe( + Effect.map((response) => + Message.SucceededListGitHub({ + version, + repos: response.data.map((repo) => ({ + id: repo.id, + repositoryFullName: repo.repositoryFullName, + enabledEvents: [...repo.enabledEvents], + branchFilter: repo.branchFilter, + isEnabled: repo.isEnabled, + })), + }), + ), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed( + Message.FailedList({ kind: "github", version, reason: "ChannelNotFound" }), + ), + }), + catchCommon((reason) => Message.FailedList({ kind: "github", version, reason })), + ), }) // ROW ACTIONS (each row's toggle and delete, with its own messages) -const notFound = (title: string, description: string): ErrorMessage => ({ - title, - description, - isRetryable: false, -}) - +/** Each row kind's success copy; update has the failure copy. */ const rowCopy = { webhook: { - tag: "ChannelWebhookNotFoundError", - title: "Webhook not found", toggled: (isEnabled: boolean) => (isEnabled ? "Webhook enabled" : "Webhook disabled"), deleted: "Webhook deleted", - noun: "webhook", }, rss: { - tag: "RssSubscriptionNotFoundError", - title: "Subscription not found", toggled: (isEnabled: boolean) => (isEnabled ? "Feed resumed" : "Feed paused"), deleted: "Feed removed", - noun: "subscription", }, github: { - tag: "GitHubSubscriptionNotFoundError", - title: "Subscription not found", toggled: (isEnabled: boolean) => (isEnabled ? "Subscription enabled" : "Subscription disabled"), deleted: "Subscription removed", - noun: "subscription", }, } as const @@ -167,31 +136,25 @@ const rowRequest = (kind: RowKind, id: string, isEnabled: boolean | null) => export const RunRowAction = Command.define("RunRowAction", { args: { kind: RowKind, id: Schema.String, isEnabled: Schema.NullOr(Schema.Boolean) }, messages: [Message.SucceededRowAction, Message.FailedRowAction], - execute: ({ kind, id, isEnabled }) => - Effect.gen(function* () { - const copy = rowCopy[kind] - const exit = yield* Effect.exit(rowRequest(kind, id, isEnabled)) - const description = - isEnabled === null - ? `This ${copy.noun} may have already been deleted.` - : `This ${copy.noun} may have been deleted.` - return Exit.match(exit, { - onSuccess: () => - Message.SucceededRowAction({ - kind, - id, - successMessage: isEnabled === null ? copy.deleted : copy.toggled(isEnabled), - }), - onFailure: (cause) => - Message.FailedRowAction({ - kind, - id, - toast: failureToast(cause, "exitToast", { - [copy.tag]: notFound(copy.title, description), - }), - }), - }) - }), + execute: ({ kind, id, isEnabled }) => { + const notFound = () => Effect.succeed(Message.FailedRowAction({ kind, id, reason: "NotFound" })) + return rowRequest(kind, id, isEnabled).pipe( + Effect.as( + Message.SucceededRowAction({ + kind, + id, + successMessage: + isEnabled === null ? rowCopy[kind].deleted : rowCopy[kind].toggled(isEnabled), + }), + ), + Effect.catchTags({ + ChannelWebhookNotFoundError: notFound, + RssSubscriptionNotFoundError: notFound, + GitHubSubscriptionNotFoundError: notFound, + }), + catchCommon((reason) => Message.FailedRowAction({ kind, id, reason })), + ) + }, }) // INTEGRATION CARDS @@ -208,61 +171,54 @@ export const PROVIDER_LOGOS: Readonly<Record<Provider, string>> = { export const ConnectProvider = Command.define("ConnectProvider", { args: { channelId: ChannelId, provider: Provider }, messages: [Message.SucceededConnectProvider, Message.FailedConnectProvider], - execute: ({ channelId, provider }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const config = INTEGRATION_CONFIG[provider] - const exit = yield* Effect.exit( - client("channelWebhook.create", { - channelId, - name: config.name, - description: config.webhookDescription, - avatarUrl: PROVIDER_LOGOS[provider], - integrationProvider: provider, - }), - ) - return Exit.match(exit, { - onSuccess: (result) => Message.SucceededConnectProvider({ provider, token: result.token }), - onFailure: (cause) => - Message.FailedConnectProvider({ - provider, - toast: failureToast(cause, "exitToast", channelNotFound), - }), - }) - }), + execute: ({ channelId, provider }) => { + const config = INTEGRATION_CONFIG[provider] + return HazelRpc.use((rpc) => + rpc("channelWebhook.create", { + channelId, + name: config.name, + description: config.webhookDescription, + avatarUrl: PROVIDER_LOGOS[provider], + integrationProvider: provider, + }), + ).pipe( + Effect.map((result) => Message.SucceededConnectProvider({ provider, token: result.token })), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed(Message.FailedConnectProvider({ provider, reason: "ChannelNotFound" })), + }), + catchCommon((reason) => Message.FailedConnectProvider({ provider, reason })), + ) + }, }) /** The provider card's Enable/Disable and Delete (`isEnabled: null`). */ export const RunProviderAction = Command.define("RunProviderAction", { args: { provider: Provider, webhookId: ChannelWebhookId, isEnabled: Schema.NullOr(Schema.Boolean) }, messages: [Message.SucceededProviderAction, Message.FailedProviderAction], - execute: ({ provider, webhookId, isEnabled }) => - Effect.gen(function* () { - const name = INTEGRATION_CONFIG[provider].name - const exit = yield* Effect.exit(rowRequest("webhook", webhookId, isEnabled)) - return Exit.match(exit, { - onSuccess: () => - Message.SucceededProviderAction({ - provider, - isDelete: isEnabled === null, - successMessage: - isEnabled === null - ? `${name} disconnected` - : `${name} ${isEnabled ? "enabled" : "disabled"}`, - }), - onFailure: (cause) => - Message.FailedProviderAction({ - provider, - isDelete: isEnabled === null, - toast: failureToast(cause, "exitToast", { - ChannelWebhookNotFoundError: notFound( - "Webhook not found", - "This webhook may have been deleted.", - ), - }), - }), - }) - }), + execute: ({ provider, webhookId, isEnabled }) => { + const name = INTEGRATION_CONFIG[provider].name + return HazelRpc.use((rpc) => + isEnabled === null + ? rpc("channelWebhook.delete", { id: webhookId }) + : rpc("channelWebhook.update", { id: webhookId, isEnabled }), + ).pipe( + Effect.as( + Message.SucceededProviderAction({ + provider, + successMessage: + isEnabled === null + ? `${name} disconnected` + : `${name} ${isEnabled ? "enabled" : "disabled"}`, + }), + ), + Effect.catchTags({ + ChannelWebhookNotFoundError: () => + Effect.succeed(Message.FailedProviderAction({ provider, reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedProviderAction({ provider, reason })), + ) + }, }) export const WaitForConfirmReset = Command.define("WaitForConfirmReset", { @@ -278,27 +234,30 @@ export const CreateWebhook = Command.define("CreateWebhook", { args: { channelId: ChannelId, name: Schema.String, description: Schema.String, avatarUrl: Schema.String }, messages: [Message.SucceededCreateWebhook, Message.FailedCreateWebhook], execute: ({ channelId, name, description, avatarUrl }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("channelWebhook.create", { - channelId, - name, - description: description || undefined, - avatarUrl: avatarUrl || undefined, - }), - ) - return Exit.match(exit, { - onSuccess: (result) => - Message.SucceededCreateWebhook({ token: result.token, webhookUrl: result.webhookUrl }), - onFailure: (cause) => - Message.FailedCreateWebhook({ toast: failureToast(cause, "exitToast", channelNotFound) }), - }) - }), + HazelRpc.use((rpc) => + rpc("channelWebhook.create", { + channelId, + name, + description: description || undefined, + avatarUrl: avatarUrl || undefined, + }), + ).pipe( + Effect.map((result) => + Message.SucceededCreateWebhook({ token: result.token, webhookUrl: result.webhookUrl }), + ), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed(Message.FailedCreateWebhook({ reason: "ChannelNotFound" })), + }), + catchCommon((reason) => Message.FailedCreateWebhook({ reason })), + ), }) // CLIPBOARD +/** `navigator.clipboard.writeText` rejected (permission, insecure context). */ +class ClipboardWriteError extends Schema.TaggedError<ClipboardWriteError>()("ClipboardWriteError", {}) {} + export const CopyText = Command.define("CopyText", { args: { id: Schema.String, @@ -306,12 +265,15 @@ export const CopyText = Command.define("CopyText", { successMessage: Schema.String, failureMessage: Schema.String, }, - messages: [Message.CompletedCopy], + messages: [Message.SucceededCopy, Message.FailedCopy], execute: ({ id, value, successMessage, failureMessage }) => - Effect.tryPromise(() => navigator.clipboard.writeText(value)).pipe( - Effect.match({ - onSuccess: () => Message.CompletedCopy({ id, isCopied: true, toastTitle: successMessage }), - onFailure: () => Message.CompletedCopy({ id, isCopied: false, toastTitle: failureMessage }), + Effect.tryPromise({ + try: () => navigator.clipboard.writeText(value), + catch: () => new ClipboardWriteError(), + }).pipe( + Effect.as(Message.SucceededCopy({ id, successMessage })), + Effect.catchTags({ + ClipboardWriteError: () => Effect.succeed(Message.FailedCopy({ failureMessage })), }), ), }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/message.ts b/apps/web-foldkit/src/page/channel-settings/integrations/message.ts index 1f9b97254..6908ab49a 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/message.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/message.ts @@ -1,11 +1,16 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" import * as Menu from "../../../ui/menu" -import * as Modal from "../../../ui/modal" -import { GitHubRepo, Provider, RssFeed, RowKind, Webhook } from "./model" - -const Failure = { toast: ToastRequest } +import * as ConfirmAction from "../../shared/confirm-action" +import { + ChannelRequestFailure, + GitHubRepo, + Provider, + RowActionFailure, + RssFeed, + RowKind, + Webhook, +} from "./model" /** A row menu's identity: the list it sits in and the row id. */ const RowMenuId = Schema.Struct({ kind: RowKind, id: Schema.String }) @@ -15,16 +20,17 @@ export const Message = defineMessageUnion({ SucceededListWebhooks: { version: Schema.Number, webhooks: Schema.Array(Webhook) }, SucceededListRss: { version: Schema.Number, feeds: Schema.Array(RssFeed) }, SucceededListGitHub: { version: Schema.Number, repos: Schema.Array(GitHubRepo) }, - /** A list failed: legacy keeps the rows it had, stops loading, and toasts. */ - FailedList: { list: Schema.Literals(["webhooks", "rss", "github"]), version: Schema.Number, ...Failure }, + /** A list failed: it keeps the rows it had (`Stale`), or shows the failure with a retry. */ + FailedList: { kind: RowKind, version: Schema.Number, reason: ChannelRequestFailure }, + ClickedRetryList: { kind: RowKind }, ClickedConnectGitHub: {}, ClickedAddRepo: {}, ClickedAddFeed: {}, GotRowMenuMessage: { rowMenuId: RowMenuId, message: Menu.Message }, SucceededRowAction: { kind: RowKind, id: Schema.String, successMessage: Schema.String }, - FailedRowAction: { kind: RowKind, id: Schema.String, ...Failure }, + FailedRowAction: { kind: RowKind, id: Schema.String, reason: RowActionFailure }, ClickedConfirmRemove: {}, - GotConfirmModalMessage: { message: Modal.Message }, + GotRemoveConfirmMessage: { message: ConfirmAction.Message }, /** `navigator.clipboard.writeText` with the button's success and failure toasts. */ ClickedCopy: { id: Schema.String, @@ -32,16 +38,18 @@ export const Message = defineMessageUnion({ successMessage: Schema.String, failureMessage: Schema.String, }, - CompletedCopy: { id: Schema.String, isCopied: Schema.Boolean, toastTitle: Schema.String }, + SucceededCopy: { id: Schema.String, successMessage: Schema.String }, + /** The clipboard refused the write; the copy is the button's own, not error text. */ + FailedCopy: { failureMessage: Schema.String }, ElapsedCopiedDelay: { id: Schema.String }, ClickedConnectProvider: { provider: Provider }, SucceededConnectProvider: { provider: Provider, token: Schema.String }, - FailedConnectProvider: { provider: Provider, ...Failure }, + FailedConnectProvider: { provider: Provider, reason: ChannelRequestFailure }, ClickedToggleProvider: { provider: Provider }, ClickedDeleteProvider: { provider: Provider }, ElapsedConfirmDelay: { provider: Provider, version: Schema.Number }, - SucceededProviderAction: { provider: Provider, successMessage: Schema.String, isDelete: Schema.Boolean }, - FailedProviderAction: { provider: Provider, isDelete: Schema.Boolean, ...Failure }, + SucceededProviderAction: { provider: Provider, successMessage: Schema.String }, + FailedProviderAction: { provider: Provider, reason: RowActionFailure }, ClickedProviderUrlInfo: {}, ClickedDismissProviderToken: { provider: Provider }, ClickedExpandCreateForm: {}, @@ -52,7 +60,7 @@ export const Message = defineMessageUnion({ }, SubmittedCreateForm: {}, SucceededCreateWebhook: { token: Schema.String, webhookUrl: Schema.String }, - FailedCreateWebhook: Failure, + FailedCreateWebhook: { reason: ChannelRequestFailure }, ClickedToggleTokenVisible: {}, ClickedDismissToken: {}, }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/model.ts b/apps/web-foldkit/src/page/channel-settings/integrations/model.ts index 7f136e891..30863e6a4 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/model.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/model.ts @@ -1,7 +1,11 @@ import { ChannelId, ChannelWebhookId, GitHubSubscriptionId, RssSubscriptionId } from "@hazel/schema" import { Schema } from "effect" +import * as AsyncData from "foldkit/asyncData" +import { Field, NotValidated } from "foldkit/fieldValidation" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason, failureMessage } from "~/lib/error-messages" import * as Menu from "../../../ui/menu" -import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" export const Webhook = Schema.Struct({ id: ChannelWebhookId, @@ -33,21 +37,47 @@ export const GitHubRepo = Schema.Struct({ }) export type GitHubRepo = typeof GitHubRepo.Type -/** `useState(true)` loading plus the fetched rows; `version` names the latest request, so older results drop. */ -const listOf = <S extends Schema.Top>(item: S) => - Schema.Struct({ isLoading: Schema.Boolean, version: Schema.Number, items: Schema.Array(item) }) +/** What can go wrong with a request scoped to the channel (the lists, creating a webhook). */ +export const ChannelRequestFailure = Schema.Union([Schema.Literals(["ChannelNotFound"]), CommonReason]) +export type ChannelRequestFailure = typeof ChannelRequestFailure.Type + +/** The copy for a failed channel request: a list load (rendered with a retry) or a create (a toast). */ +export const channelRequestMessage = (reason: ChannelRequestFailure) => + failureMessage(reason, { + ChannelNotFound: { title: "Channel not found", description: "This channel may have been deleted." }, + }) + +/** What can go wrong toggling or deleting a row (or a provider's webhook). */ +export const RowActionFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type RowActionFailure = typeof RowActionFailure.Type + +export const Webhooks = AsyncData.Schema(Schema.Array(Webhook), ChannelRequestFailure) +export const RssFeeds = AsyncData.Schema(Schema.Array(RssFeed), ChannelRequestFailure) +export const GitHubRepos = AsyncData.Schema(Schema.Array(GitHubRepo), ChannelRequestFailure) + +/** A fetched list; `version` names the latest request, so older results drop. */ +const listOf = <A, I>(data: Schema.Codec<A, I>) => Schema.Struct({ version: Schema.Number, data }) export const Provider = Schema.Literals(["openstatus", "railway"]) export type Provider = typeof Provider.Type +/** + * The provider card's action: connecting, toggling, the 3 s "Confirm?" before a delete, or deleting. + * Legacy keeps Enable/Disable enabled while it runs, so update ignores repeats. + */ +export const CardAction = defineTaggedUnion({ + Idle: {}, + Connecting: {}, + Toggling: {}, + ConfirmingDelete: {}, + Deleting: {}, +}) +export type CardAction = typeof CardAction.Type + /** `IntegrationCard` local state. */ export const ProviderCard = Schema.Struct({ - isCreating: Schema.Boolean, - /** Enable/Disable in flight; legacy keeps the button enabled, so update ignores repeats. */ - isToggling: Schema.Boolean, - isDeleting: Schema.Boolean, - confirmDelete: Schema.Boolean, - /** Bumped per confirm, so only the latest 3 s timeout resets it. */ + action: CardAction, + /** Bumped per "Confirm?", so only the latest 3 s timeout disarms it. */ confirmVersion: Schema.Number, createdToken: Schema.NullOr(Schema.String), }) @@ -56,43 +86,44 @@ export type ProviderCard = typeof ProviderCard.Type /** `CreateWebhookForm` state. */ export const CreateForm = Schema.Struct({ isExpanded: Schema.Boolean, - name: Schema.String, + /** `NotValidated` until the user edits the name or submits. */ + name: Field(Schema.String), description: Schema.String, avatarUrl: Schema.String, - isNameDirty: Schema.Boolean, isSubmitting: Schema.Boolean, created: Schema.NullOr(Schema.Struct({ token: Schema.String, webhookUrl: Schema.String })), isTokenVisible: Schema.Boolean, }) export type CreateForm = typeof CreateForm.Type +export const emptyCreateForm: CreateForm = { + isExpanded: false, + name: NotValidated({ value: "" }), + description: "", + avatarUrl: "", + isSubmitting: false, + created: null, + isTokenVisible: false, +} + export const RowKind = Schema.Literals(["webhook", "rss", "github"]) export type RowKind = typeof RowKind.Type -/** The row whose delete confirmation is open (each legacy row owns an alertdialog). */ +/** The row a delete confirmation is about (each legacy row owns an alertdialog). */ export const ConfirmTarget = Schema.Struct({ kind: RowKind, id: Schema.String }) export type ConfirmTarget = typeof ConfirmTarget.Type -/** The delete confirmation: no target, asking, or removing (the dialog may be dismissed meanwhile). */ -export const Confirm = Schema.Union([ - Schema.TaggedStruct("Closed", {}), - Schema.TaggedStruct("Confirming", { target: ConfirmTarget }), - Schema.TaggedStruct("Removing", { target: ConfirmTarget }), -]) -export type Confirm = typeof Confirm.Type - export const Model = Schema.Struct({ channelId: ChannelId, isGitHubConnected: Schema.Boolean, - webhooks: listOf(Webhook), - rss: listOf(RssFeed), - github: listOf(GitHubRepo), + webhooks: listOf(Webhooks.schema), + rss: listOf(RssFeeds.schema), + github: listOf(GitHubRepos.schema), rowMenus: Schema.Array(Menu.Model), togglingRowIds: Schema.Array(Schema.String), /** Copy buttons showing their check for 2 s: webhook ids, `provider:<name>`, `token`, `url`. */ copiedIds: Schema.Array(Schema.String), - confirm: Confirm, - confirmModal: Modal.Model, + remove: ConfirmAction.Model(ConfirmTarget), providers: Schema.Struct({ openstatus: ProviderCard, railway: ProviderCard }), createForm: CreateForm, }) @@ -128,4 +159,7 @@ export const INTEGRATION_CONFIG: Readonly< }, } -export const isNameValid = (name: string) => name.length > 1 && name.length < 101 +/** A list's rows, or none while it has no data. */ +export const itemsOf = <A, E>(list: { + readonly data: AsyncData.AsyncData<ReadonlyArray<A>, E> +}): ReadonlyArray<A> => AsyncData.getOrElse(list.data, () => []) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts b/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts index bb13e692e..bf5917bf8 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/provider-card.ts @@ -39,6 +39,8 @@ export const providerCard = ( const webhook = providerWebhook(model, provider) const logo = h.img([h.Src(PROVIDER_LOGOS[provider]), h.Alt(config.name), h.Class("size-10 rounded-lg")]) const isCopied = model.copiedIds.includes(`provider:${provider}`) + const isConnecting = card.action._tag === "Connecting" + const isConfirming = card.action._tag === "ConfirmingDelete" // Just created: the token is shown once. if (card.createdToken !== null && webhook !== null) { @@ -174,10 +176,10 @@ export const providerCard = ( { intent: "primary", size: "sm", - isDisabled: card.isCreating, + isDisabled: isConnecting, onPress: Message.ClickedConnectProvider({ provider }), }, - [card.isCreating ? "Connecting..." : "Connect"], + [isConnecting ? "Connecting..." : "Connect"], ), ], ), @@ -238,12 +240,12 @@ export const providerCard = ( button( h, { - intent: card.confirmDelete ? "danger" : "outline", + intent: isConfirming ? "danger" : "outline", size: "sm", - isDisabled: card.isDeleting, + isDisabled: card.action._tag === "Deleting", onPress: Message.ClickedDeleteProvider({ provider }), }, - [card.confirmDelete ? "Confirm?" : "Delete"], + [isConfirming ? "Confirm?" : "Delete"], ), ], ), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts b/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts index c6a2810c6..75d1a9eca 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/rows.ts @@ -1,10 +1,11 @@ import { Array, Option } from "effect" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" import * as Menu from "../../../ui/menu" import type { PageReturn } from "../../contract" import { ListGitHub, ListRss, ListWebhooks } from "./command" import type { Message } from "./message" -import { type Model, type RowKind, rowMenuId } from "./model" +import { itemsOf, type Model, type RowKind, rowMenuId } from "./model" /** Each row's `<Menu>` entries; the toggle item is disabled while its request runs (`isToggling`). */ export const rowEntries = (kind: RowKind, isToggling: boolean): ReadonlyArray<Menu.Entry> => [ @@ -15,11 +16,11 @@ export const rowEntries = (kind: RowKind, isToggling: boolean): ReadonlyArray<Me ] const rowKeys = (model: Model): ReadonlyArray<readonly [RowKind, string]> => [ - ...model.webhooks.items + ...itemsOf(model.webhooks) .filter((webhook) => webhook.name !== "OpenStatus" && webhook.name !== "Railway") .map((webhook) => ["webhook", webhook.id] as const), - ...model.rss.items.map((feed) => ["rss", feed.id] as const), - ...model.github.items.map((repo) => ["github", repo.id] as const), + ...itemsOf(model.rss).map((feed) => ["rss", feed.id] as const), + ...itemsOf(model.github).map((repo) => ["github", repo.id] as const), ] /** One menu per listed row; a menu whose row is still listed keeps its state. */ @@ -39,32 +40,30 @@ export const reflectRowMenus = (model: Model): Model => }), }) +/** Loading, or refreshing the rows it has: a reload never blanks a loaded list. */ +const reloading = <A, E>(data: AsyncData.AsyncData<A, E>): AsyncData.AsyncData<A, E> => + Option.getOrElse(AsyncData.revalidateOrLoad(data), () => data) + /** Each reload bumps the list's version, so only the latest response lands. */ export const reload = (model: Model, kind: RowKind): PageReturn<Model, Message> => { const { channelId } = model if (kind === "webhook") { const version = model.webhooks.version + 1 return { - model: modifyFields(model, { - webhooks: (list) => modifyFields(list, { isLoading: () => true, version: () => version }), - }), + model: modifyFields(model, { webhooks: (list) => ({ version, data: reloading(list.data) }) }), commands: [ListWebhooks({ channelId, version })], } } if (kind === "rss") { const version = model.rss.version + 1 return { - model: modifyFields(model, { - rss: (list) => modifyFields(list, { isLoading: () => true, version: () => version }), - }), + model: modifyFields(model, { rss: (list) => ({ version, data: reloading(list.data) }) }), commands: [ListRss({ channelId, version })], } } const version = model.github.version + 1 return { - model: modifyFields(model, { - github: (list) => modifyFields(list, { isLoading: () => true, version: () => version }), - }), + model: modifyFields(model, { github: (list) => ({ version, data: reloading(list.data) }) }), commands: [ListGitHub({ channelId, version })], } } diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts b/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts index a8277520c..74a7dfc23 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/rss-card.ts @@ -2,9 +2,9 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { IconPlus } from "../../../icons" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" -import { cardTitle, emptyList, menuOf, spinner } from "./cards" +import { cardTitle, emptyList, listBody, menuOf, spinner } from "./cards" import { Message } from "./message" -import type { Model, RssFeed } from "./model" +import { itemsOf, type Model, type RssFeed } from "./model" import { rowMenu } from "./row-menu" /** Port of `components/channel-settings/rss-integration-card.tsx`. */ @@ -100,7 +100,7 @@ const rssItem = (h: HtmlBuilder<Message>, model: Model, feed: RssFeed): Html => } export const rssCard = (h: HtmlBuilder<Message>, model: Model): Html => { - const feeds = model.rss.items + const feeds = itemsOf(model.rss) return h.div( [h.Class("rounded-xl border border-border bg-bg")], [ @@ -143,18 +143,20 @@ export const rssCard = (h: HtmlBuilder<Message>, model: Model): Html => { h.div( [h.Class("p-4")], [ - model.rss.isLoading - ? spinner(h) - : feeds.length === 0 - ? emptyList( - h, - "No RSS feeds subscribed", - "Add a feed to receive new articles in this channel", - ) - : h.div( - [h.Class("flex flex-col gap-2")], - feeds.map((feed) => rssItem(h, model, feed)), - ), + listBody(h, "rss", model.rss.data, { + loading: () => spinner(h), + empty: () => + emptyList( + h, + "No RSS feeds subscribed", + "Add a feed to receive new articles in this channel", + ), + rows: (items) => + h.div( + [h.Class("flex flex-col gap-2")], + items.map((feed) => rssItem(h, model, feed)), + ), + }), ], ), ], diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts index 5afccd6ce..8be4923ae 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { errorToast, successToast } from "../../../data/actions" +import { successToast } from "../../../data/actions" import { feedOf, webhookOf } from "../../../test/pages-channel-settings-fixtures" import { channelId, @@ -59,6 +59,33 @@ describe("lists", () => { Scene.expect(Scene.role("button", { name: "Connect GitHub" })).toExist(), ) }) + + test("a failed load shows the error and Try again instead of the empty state", () => { + Scene.scene( + config, + // A failed reload keeps what the list showed, under the failure. + Scene.given( + update(listed(), Message.FailedList({ kind: "rss", version: 1, reason: "Offline" }), shared) + .model, + ), + Scene.expect(Scene.text("No RSS feeds subscribed")).toExist(), + Scene.expect(Scene.text("Connection lost")).toExist(), + ) + Scene.scene( + config, + Scene.given(initial), + Scene.Subscription.emit( + Message.FailedList({ kind: "webhook", version: 1, reason: "Unavailable" }), + ), + Scene.expect(Scene.text("No webhooks yet")).toBeAbsent(), + Scene.expect(Scene.text("Service temporarily unavailable")).toExist(), + Scene.click(Scene.role("button", { name: "Try again" })), + Scene.Command.expectExact(ListWebhooks({ channelId, version: 2 })), + Scene.Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [] })), + Scene.expect(Scene.text("Service temporarily unavailable")).toBeAbsent(), + Scene.expect(Scene.text("No webhooks yet")).toExist(), + ) + }) }) describe("create webhook", () => { @@ -108,6 +135,7 @@ describe("create webhook", () => { Scene.click(createButton), Scene.type(nameInput, "a"), Scene.expect(nameInput).toHaveAttr("aria-invalid", "true"), + Scene.expect(Scene.text("Webhook name must be at least 2 characters")).toExist(), Scene.expect(createButton).toBeDisabled(), Scene.click(Scene.role("button", { name: "Cancel" })), Scene.expect(nameInput).toBeAbsent(), @@ -121,12 +149,7 @@ describe("create webhook", () => { Scene.click(createButton), Scene.type(nameInput, "Deploys"), Scene.submit(Scene.selector("form")), - Scene.Command.resolve( - CreateWebhook, - Message.FailedCreateWebhook({ - toast: errorToast(failureToastFixture.title, failureToastFixture.description), - }), - ), + Scene.Command.resolve(CreateWebhook, Message.FailedCreateWebhook({ reason: "Unexpected" })), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), Scene.expect(nameInput).toHaveValue("Deploys"), Scene.expect(createButton).toBeEnabled(), @@ -169,10 +192,7 @@ describe("provider cards", () => { Scene.click(connectOpenStatus), Scene.Command.resolve( ConnectProvider, - Message.FailedConnectProvider({ - provider: "openstatus", - toast: errorToast(failureToastFixture.title, failureToastFixture.description), - }), + Message.FailedConnectProvider({ provider: "openstatus", reason: "Unexpected" }), ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), Scene.expect(connectOpenStatus).toBeEnabled(), @@ -192,7 +212,6 @@ describe("provider cards", () => { Message.SucceededProviderAction({ provider: "openstatus", successMessage: "OpenStatus disabled", - isDelete: false, }), ), Scene.expectOutMessage( @@ -250,8 +269,9 @@ describe("row removal", () => { Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Webhook deleted") })), Scene.expect(confirmDialog).toBeAbsent(), Scene.Mount.expectEnded(Modal.PortalModal), - Scene.Mount.expectEnded(FocusTriggerOnPress), + // The reload keeps the row (and its menu) on screen until the new list drops it. Scene.Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [] })), + Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.expect(Scene.text("No webhooks yet")).toExist(), ) }) @@ -282,12 +302,11 @@ describe("rss feeds", () => { Message.SucceededRowAction({ kind: "rss", id: feed.id, successMessage: "Feed paused" }), ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Feed paused") })), - Scene.Mount.expectEnded(FocusTriggerOnPress), + // The reload keeps the row and its menu mounted. Scene.Command.resolve( ListRss, Message.SucceededListRss({ version: 2, feeds: [{ ...feed, isEnabled: false }] }), ), - menuMounted, Scene.expect(Scene.text("Paused")).toExist(), ) }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts index c31d24f5f..1c2f8b3a1 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts @@ -1,13 +1,13 @@ -import "../document-stub" +import "../../../test/document-stub" import { ChannelId, ChannelWebhookId } from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { errorToast } from "../../../data/actions" import * as Menu from "../../../ui/menu" import type { Shared } from "../../contract" import { PageOutMessage } from "../../out-message" import * as Modal from "../../../ui/modal" +import * as ConfirmAction from "../../shared/confirm-action" import { ConnectProvider, CopyText, @@ -20,6 +20,7 @@ import { WaitForCopiedReset, } from "./command" import { Message } from "./message" +import { Webhooks } from "./model" import { init, update } from "./update" import { sharedDefaults } from "../../test-shared" @@ -81,7 +82,8 @@ describe("channel integrations", () => { toast: { intent: "success", title: "Webhook disabled", description: null }, }), ), - model((current) => expect(current.webhooks.isLoading).toBe(true)), + // The reload keeps the rows on screen. + model((current) => expect(current.webhooks.data._tag).toBe("Refreshing")), Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [] })), ) }) @@ -91,30 +93,29 @@ describe("channel integrations", () => { run, given(loaded), message(Message.ClickedDeleteProvider({ provider: "openstatus" })), - model((current) => expect(current.providers.openstatus.confirmDelete).toBe(true)), + model((current) => expect(current.providers.openstatus.action._tag).toBe("ConfirmingDelete")), Command.resolve( WaitForConfirmReset, Message.ElapsedConfirmDelay({ provider: "openstatus", version: 1 }), ), - model((current) => expect(current.providers.openstatus.confirmDelete).toBe(false)), + model((current) => expect(current.providers.openstatus.action._tag).toBe("Idle")), message(Message.ClickedDeleteProvider({ provider: "openstatus" })), Command.resolve( WaitForConfirmReset, Message.ElapsedConfirmDelay({ provider: "openstatus", version: 1 }), ), // A stale timeout (version 1) leaves the re-armed confirm (version 2) alone. - model((current) => expect(current.providers.openstatus.confirmDelete).toBe(true)), + model((current) => expect(current.providers.openstatus.action._tag).toBe("ConfirmingDelete")), message(Message.ClickedDeleteProvider({ provider: "openstatus" })), Command.resolve( RunProviderAction, Message.SucceededProviderAction({ provider: "openstatus", successMessage: "OpenStatus disconnected", - isDelete: true, }), ), Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [ci] })), - model((current) => expect(current.providers.openstatus.confirmDelete).toBe(false)), + model((current) => expect(current.providers.openstatus.action._tag).toBe("Idle")), ) }) }) @@ -127,23 +128,34 @@ describe("list failure", () => { ]) }) - test("a failed webhook list stops loading and toasts", () => { + test("a failed webhook list is a Failure with its reason, not an empty list; Try again reloads", () => { story( run, given(initial.model), - message( - Message.FailedList({ - list: "webhooks", - version: 1, - toast: errorToast("Channel not found"), - }), + message(Message.FailedList({ kind: "webhook", version: 1, reason: "Offline" })), + model((current) => expect(current.webhooks.data).toEqual(Webhooks.Failure({ error: "Offline" }))), + message(Message.ClickedRetryList({ kind: "webhook" })), + Command.expectExact(ListWebhooks({ channelId, version: 2 })), + model((current) => expect(current.webhooks.data._tag).toBe("Loading")), + Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [ci] })), + model((current) => expect(current.webhooks.data).toEqual(Webhooks.Success({ data: [ci] }))), + ) + }) + + test("a failed reload keeps the rows it had (Stale)", () => { + story( + run, + given(loaded), + message(Message.ClickedRetryList({ kind: "webhook" })), + Command.resolve( + ListWebhooks, + Message.FailedList({ kind: "webhook", version: 2, reason: "Timeout" }), ), - expectOutMessage( - PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Channel not found", description: null }, - }), + model((current) => + expect(current.webhooks.data).toEqual( + Webhooks.Stale({ error: "Timeout", data: [ci, openStatus] }), + ), ), - model((current) => expect(current.webhooks.isLoading).toBe(false)), ) }) }) @@ -158,14 +170,17 @@ const confirming = [ describe("row removal", () => { test("Delete from the menu opens the confirm dialog, and confirming deletes and reloads", () => { - expect(confirming.confirm).toEqual({ _tag: "Confirming", target: { kind: "webhook", id: ci.id } }) - expect(confirming.confirmModal.isOpen).toBe(true) + expect(confirming.remove.state).toEqual({ + _tag: "Confirming", + target: { kind: "webhook", id: ci.id }, + }) + expect(confirming.remove.modal.isOpen).toBe(true) story( run, given(confirming), message(Message.ClickedConfirmRemove()), Command.expectExact(RunRowAction({ kind: "webhook", id: ci.id, isEnabled: null })), - model((current) => expect(current.confirm._tag).toBe("Removing")), + model((current) => expect(current.remove.state._tag).toBe("Running")), Command.resolve( RunRowAction, Message.SucceededRowAction({ kind: "webhook", id: ci.id, successMessage: "Webhook deleted" }), @@ -176,8 +191,8 @@ describe("row removal", () => { }), ), model((current) => { - expect(current.confirmModal.isOpen).toBe(false) - expect(current.confirm).toEqual({ _tag: "Closed" }) + expect(current.remove.modal.isOpen).toBe(false) + expect(current.remove.state).toEqual({ _tag: "Closed" }) }), Command.resolve( ListWebhooks, @@ -186,26 +201,29 @@ describe("row removal", () => { ) }) - test("a failed delete closes the dialog and toasts, without reloading", () => { + test("a failed delete toasts and asks again, without reloading", () => { story( run, given(confirming), message(Message.ClickedConfirmRemove()), Command.resolve( RunRowAction, - Message.FailedRowAction({ - kind: "webhook", - id: ci.id, - toast: errorToast("Webhook not found"), - }), + Message.FailedRowAction({ kind: "webhook", id: ci.id, reason: "NotFound" }), ), Command.expectNone(), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Webhook not found", description: null }, + toast: { + intent: "error", + title: "Webhook not found", + description: "This webhook may have already been deleted.", + }, }), ), - model((current) => expect(current.confirmModal.isOpen).toBe(false)), + model((current) => { + expect(current.remove.modal.isOpen).toBe(true) + expect(current.remove.state._tag).toBe("Confirming") + }), ) }) @@ -215,26 +233,27 @@ describe("row removal", () => { }) test("an older list response is dropped once a newer reload is out", () => { - const reloading = { ...loaded, webhooks: { ...loaded.webhooks, isLoading: true, version: 3 } } + const reloading = { + ...loaded, + webhooks: { version: 3, data: Webhooks.Refreshing({ data: [ci, openStatus] }) }, + } const stale = run(reloading, Message.SucceededListWebhooks({ version: 2, webhooks: [] })) - expect(stale.model.webhooks).toMatchObject({ isLoading: true, items: [ci, openStatus] }) + expect(stale.model.webhooks).toEqual(reloading.webhooks) const staleFailure = run( reloading, - Message.FailedList({ - list: "webhooks", - version: 2, - toast: errorToast("Channel not found"), - }), + Message.FailedList({ kind: "webhook", version: 2, reason: "ChannelNotFound" }), ) expect(staleFailure.outMessage).toBeUndefined() - expect(staleFailure.model.webhooks.isLoading).toBe(true) + expect(staleFailure.model.webhooks).toEqual(reloading.webhooks) }) test("Escape while a delete runs does not wedge the next removal", () => { const pending = run(confirming, Message.ClickedConfirmRemove()).model const escaped = run( pending, - Message.GotConfirmModalMessage({ message: Modal.Message.PressedEscape() }), + Message.GotRemoveConfirmMessage({ + message: ConfirmAction.toModalMessage(Modal.Message.PressedEscape()), + }), ).model const settled = run( escaped, @@ -253,7 +272,7 @@ describe("provider cards", () => { given(loaded), message(Message.ClickedConnectProvider({ provider: "railway" })), Command.expectExact(ConnectProvider({ channelId, provider: "railway" })), - model((current) => expect(current.providers.railway.isCreating).toBe(true)), + model((current) => expect(current.providers.railway.action._tag).toBe("Connecting")), Command.resolve( ConnectProvider, Message.SucceededConnectProvider({ provider: "railway", token: "tok" }), @@ -264,8 +283,11 @@ describe("provider cards", () => { }), ), model((current) => { - expect(current.providers.railway).toMatchObject({ isCreating: false, createdToken: "tok" }) - expect(current.webhooks.isLoading).toBe(true) + expect(current.providers.railway).toMatchObject({ + action: { _tag: "Idle" }, + createdToken: "tok", + }) + expect(current.webhooks.data._tag).toBe("Refreshing") }), Command.resolve( ListWebhooks, @@ -287,18 +309,17 @@ describe("provider cards", () => { story( run, given(connecting), - message( - Message.FailedConnectProvider({ - provider: "railway", - toast: errorToast("Channel not found"), - }), - ), + message(Message.FailedConnectProvider({ provider: "railway", reason: "ChannelNotFound" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Channel not found", description: null }, + toast: { + intent: "error", + title: "Channel not found", + description: "This channel may have been deleted.", + }, }), ), - model((current) => expect(current.providers.railway.isCreating).toBe(false)), + model((current) => expect(current.providers.railway.action._tag).toBe("Idle")), ) }) @@ -312,15 +333,15 @@ describe("provider cards", () => { ), Command.resolve( RunProviderAction, - Message.FailedProviderAction({ - provider: "openstatus", - isDelete: false, - toast: errorToast("Webhook not found"), - }), + Message.FailedProviderAction({ provider: "openstatus", reason: "NotFound" }), ), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Webhook not found", description: null }, + toast: { + intent: "error", + title: "Webhook not found", + description: "This webhook may have been deleted.", + }, }), ), ) @@ -334,13 +355,9 @@ describe("provider cards", () => { ).toHaveLength(0) const failed = run( toggling.model, - Message.FailedProviderAction({ - provider: "openstatus", - isDelete: false, - toast: errorToast("Failed"), - }), + Message.FailedProviderAction({ provider: "openstatus", reason: "Unexpected" }), ) - expect(failed.model.providers.openstatus.isToggling).toBe(false) + expect(failed.model.providers.openstatus.action._tag).toBe("Idle") }) test("a second Delete click while deleting sends nothing", () => { @@ -418,15 +435,27 @@ describe("create webhook form", () => { ) }) - test("a short or empty name does not submit", () => { + test("a short or empty name does not submit, and says why", () => { story( run, given(loaded), message(Message.SubmittedCreateForm()), Command.expectNone(), + model((current) => + expect(current.createForm.name).toEqual({ + _tag: "Invalid", + value: "", + errors: ["Webhook name is required"], + }), + ), message(field("name", "a")), message(Message.SubmittedCreateForm()), Command.expectNone(), + model((current) => + expect(current.createForm.name).toMatchObject({ + errors: ["Webhook name must be at least 2 characters"], + }), + ), ) }) @@ -439,14 +468,18 @@ describe("create webhook form", () => { story( run, given(submitting), - message(Message.FailedCreateWebhook({ toast: errorToast("Channel not found") })), + message(Message.FailedCreateWebhook({ reason: "Offline" })), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Channel not found", description: null }, + toast: { + intent: "error", + title: "Connection lost", + description: "Check your internet connection and try again.", + }, }), ), model((current) => - expect(current.createForm).toMatchObject({ name: "Deploys", isSubmitting: false }), + expect(current.createForm).toMatchObject({ name: { value: "Deploys" }, isSubmitting: false }), ), ) }) @@ -467,10 +500,7 @@ describe("copy and GitHub", () => { message(Message.ClickedCopy(copy)), // Matched by Definition: update passes the whole Message (with `_tag`) as the args. Command.expectExact(CopyText), - Command.resolve( - CopyText, - Message.CompletedCopy({ id: ci.id, isCopied: true, toastTitle: "URL copied" }), - ), + Command.resolve(CopyText, Message.SucceededCopy({ id: ci.id, successMessage: "URL copied" })), expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "success", title: "URL copied", description: null }, @@ -486,7 +516,7 @@ describe("copy and GitHub", () => { story( run, given(loaded), - message(Message.CompletedCopy({ id: ci.id, isCopied: false, toastTitle: "Failed to copy" })), + message(Message.FailedCopy({ failureMessage: "Failed to copy" })), Command.expectNone(), expectOutMessage( PageOutMessage.RequestedToast({ @@ -502,7 +532,7 @@ describe("copy and GitHub", () => { given(loaded), message(Message.UpdatedGitHubConnection({ isConnected: true })), Command.expectExact(ListGitHub({ channelId, version: 1 })), - model((current) => expect(current.github.isLoading).toBe(true)), + model((current) => expect(current.github.data._tag).toBe("Loading")), Command.resolve(ListGitHub, Message.SucceededListGitHub({ version: 1, repos: [] })), message(Message.UpdatedGitHubConnection({ isConnected: true })), Command.expectNone(), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/update-cards.ts b/apps/web-foldkit/src/page/channel-settings/integrations/update-cards.ts index 750f23296..0a60c11b2 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/update-cards.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/update-cards.ts @@ -1,18 +1,25 @@ +import { isInvalid, isValid, validate } from "foldkit/fieldValidation" import { modifyFields } from "foldkit/struct" -import { infoToast, successToast } from "../../../data/actions" +import { failureMessage } from "~/lib/error-messages" +import { errorToastOf, infoToast, successToast } from "../../../data/actions" import type { PageReturn } from "../../contract" import { PageOutMessage } from "../../out-message" +import { webhookNameRules } from "../name-rules" import { ConnectProvider, CreateWebhook, RunProviderAction, WaitForConfirmReset } from "./command" import type { Message } from "./message" -import { reload } from "./rows" import { + CardAction, + type ChannelRequestFailure, + channelRequestMessage, + emptyCreateForm, type CreateForm, INTEGRATION_CONFIG, - isNameValid, + itemsOf, type Model, type Provider, type ProviderCard, } from "./model" +import { reload } from "./rows" /** The IntegrationCard and CreateWebhookForm Messages, folded into the page's `Message.match`. */ @@ -22,29 +29,26 @@ type Handlers = { readonly [T in Message["_tag"]]: (message: Extract<Message, { const withCard = (model: Model, provider: Provider, f: (card: ProviderCard) => ProviderCard): Model => modifyFields(model, { providers: (providers) => ({ ...providers, [provider]: f(providers[provider]) }) }) +const withAction = (model: Model, provider: Provider, action: CardAction): Model => + withCard(model, provider, (card) => ({ ...card, action })) + const withForm = (model: Model, f: (form: CreateForm) => CreateForm): Model => modifyFields(model, { createForm: f }) /** The webhook the provider card manages (`webhooks.find((w) => w.name === "OpenStatus")`). */ export const providerWebhook = (model: Model, provider: Provider) => - model.webhooks.items.find((webhook) => webhook.name === INTEGRATION_CONFIG[provider].name) ?? null + itemsOf(model.webhooks).find((webhook) => webhook.name === INTEGRATION_CONFIG[provider].name) ?? null const refetchWebhooks = (model: Model) => reload(model, "webhook") -const emptyForm: CreateForm = { - isExpanded: false, - name: "", - description: "", - avatarUrl: "", - isNameDirty: false, - isSubmitting: false, - created: null, - isTokenVisible: false, -} +const channelRequestToast = (reason: ChannelRequestFailure) => errorToastOf(channelRequestMessage(reason)) + +/** TanStack Form `canSubmit`: not submitting, and the name has not failed its rules. */ +export const canCreate = (form: CreateForm) => !form.isSubmitting && !isInvalid(form.name) -/** TanStack Form `canSubmit` with the `1<string<101` name rule (validated once the field changed). */ -export const canCreate = (form: CreateForm) => - !form.isSubmitting && !(form.isNameDirty && !isNameValid(form.name)) +/** Toggle and Delete start from a resting card; an armed "Confirm?" is a resting card too. */ +const isResting = (card: ProviderCard) => + card.action._tag === "Idle" || card.action._tag === "ConfirmingDelete" export const updateCards = ( model: Model, @@ -70,35 +74,34 @@ export const updateCards = ( | "ClickedDismissToken" > => ({ ClickedConnectProvider: ({ provider }) => - model.providers[provider].isCreating - ? { model } - : { - model: withCard(model, provider, (card) => ({ ...card, isCreating: true })), + model.providers[provider].action._tag === "Idle" + ? { + model: withAction(model, provider, CardAction.Connecting()), commands: [ConnectProvider({ channelId: model.channelId, provider })], - }, - SucceededConnectProvider: ({ provider, token }) => { - const next = withCard(model, provider, (card) => ({ - ...card, - isCreating: false, - createdToken: token, - })) - return { - ...refetchWebhooks(next), - outMessage: PageOutMessage.RequestedToast({ - toast: successToast(`${INTEGRATION_CONFIG[provider].name} connected`), - }), - } - }, - FailedConnectProvider: ({ provider, toast }) => ({ - model: withCard(model, provider, (card) => ({ ...card, isCreating: false })), - outMessage: PageOutMessage.RequestedToast({ toast }), + } + : { model }, + SucceededConnectProvider: ({ provider, token }) => ({ + ...refetchWebhooks( + withCard(model, provider, (card) => ({ + ...card, + action: CardAction.Idle(), + createdToken: token, + })), + ), + outMessage: PageOutMessage.RequestedToast({ + toast: successToast(`${INTEGRATION_CONFIG[provider].name} connected`), + }), + }), + FailedConnectProvider: ({ provider, reason }) => ({ + model: withAction(model, provider, CardAction.Idle()), + outMessage: PageOutMessage.RequestedToast({ toast: channelRequestToast(reason) }), }), ClickedToggleProvider: ({ provider }) => { const webhook = providerWebhook(model, provider) - return webhook === null || model.providers[provider].isToggling + return webhook === null || !isResting(model.providers[provider]) ? { model } : { - model: withCard(model, provider, (card) => ({ ...card, isToggling: true })), + model: withAction(model, provider, CardAction.Toggling()), commands: [ RunProviderAction({ provider, webhookId: webhook.id, isEnabled: !webhook.isEnabled }), ], @@ -108,42 +111,46 @@ export const updateCards = ( ClickedDeleteProvider: ({ provider }) => { const webhook = providerWebhook(model, provider) const card = model.providers[provider] - if (webhook === null || card.isDeleting) return { model } - if (!card.confirmDelete) { - const version = card.confirmVersion + 1 + if (webhook === null) return { model } + if (card.action._tag === "ConfirmingDelete") return { - model: withCard(model, provider, (current) => ({ - ...current, - confirmDelete: true, - confirmVersion: version, - })), - commands: [WaitForConfirmReset({ provider, version })], + model: withAction(model, provider, CardAction.Deleting()), + commands: [RunProviderAction({ provider, webhookId: webhook.id, isEnabled: null })], } - } + if (card.action._tag !== "Idle") return { model } + const version = card.confirmVersion + 1 return { - model: withCard(model, provider, (current) => ({ ...current, isDeleting: true })), - commands: [RunProviderAction({ provider, webhookId: webhook.id, isEnabled: null })], + model: withCard(model, provider, (current) => ({ + ...current, + action: CardAction.ConfirmingDelete(), + confirmVersion: version, + })), + commands: [WaitForConfirmReset({ provider, version })], } }, ElapsedConfirmDelay: ({ provider, version }) => ({ model: withCard(model, provider, (card) => - card.confirmVersion === version ? { ...card, confirmDelete: false } : card, + card.action._tag === "ConfirmingDelete" && card.confirmVersion === version + ? { ...card, action: CardAction.Idle() } + : card, ), }), - SucceededProviderAction: ({ provider, successMessage, isDelete }) => { - const next = withCard(model, provider, (card) => - isDelete ? { ...card, isDeleting: false, confirmDelete: false } : { ...card, isToggling: false }, - ) - return { - ...refetchWebhooks(next), - outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), - } - }, - FailedProviderAction: ({ provider, isDelete, toast }) => ({ - model: withCard(model, provider, (card) => - isDelete ? { ...card, isDeleting: false } : { ...card, isToggling: false }, - ), - outMessage: PageOutMessage.RequestedToast({ toast }), + SucceededProviderAction: ({ provider, successMessage }) => ({ + ...refetchWebhooks(withAction(model, provider, CardAction.Idle())), + outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), + }), + FailedProviderAction: ({ provider, reason }) => ({ + model: withAction(model, provider, CardAction.Idle()), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Webhook not found", + description: "This webhook may have been deleted.", + }, + }), + ), + }), }), ClickedProviderUrlInfo: () => ({ model, @@ -157,47 +164,44 @@ export const updateCards = ( ClickedExpandCreateForm: () => ({ model: withForm(model, (form) => ({ ...form, isExpanded: true })) }), ClickedCancelCreateForm: () => ({ model: withForm(model, (form) => ({ ...form, isExpanded: false })) }), ChangedCreateField: ({ field, value }) => ({ - model: withForm(model, (form) => ({ - ...form, - [field]: value, - isNameDirty: form.isNameDirty || field === "name", - })), + model: withForm(model, (form) => + field === "name" + ? { ...form, name: validate(webhookNameRules)(value) } + : { ...form, [field]: value }, + ), }), SubmittedCreateForm: () => { const form = model.createForm - if (!canCreate(form) || !isNameValid(form.name)) return { model } + if (form.isSubmitting) return { model } + // Submitting an untouched name shows its rule's message instead of sending. + const name = validate(webhookNameRules)(form.name.value) + if (!isValid(webhookNameRules)(name)) + return { model: withForm(model, (current) => ({ ...current, name })) } return { - model: withForm(model, (current) => ({ ...current, isSubmitting: true })), + model: withForm(model, (current) => ({ ...current, name, isSubmitting: true })), commands: [ CreateWebhook({ channelId: model.channelId, - name: form.name, + name: name.value, description: form.description, avatarUrl: form.avatarUrl, }), ], } }, - SucceededCreateWebhook: ({ token, webhookUrl }) => { - const next = withForm(model, (form) => ({ - ...form, - isSubmitting: false, - created: { token, webhookUrl }, - })) - return { - ...refetchWebhooks(next), - outMessage: PageOutMessage.RequestedToast({ - toast: successToast("Webhook created successfully"), - }), - } - }, - FailedCreateWebhook: ({ toast }) => ({ + SucceededCreateWebhook: ({ token, webhookUrl }) => ({ + ...refetchWebhooks( + withForm(model, (form) => ({ ...form, isSubmitting: false, created: { token, webhookUrl } })), + ), + outMessage: PageOutMessage.RequestedToast({ toast: successToast("Webhook created successfully") }), + }), + FailedCreateWebhook: ({ reason }) => ({ model: withForm(model, (form) => ({ ...form, isSubmitting: false })), - outMessage: PageOutMessage.RequestedToast({ toast }), + outMessage: PageOutMessage.RequestedToast({ toast: channelRequestToast(reason) }), }), ClickedToggleTokenVisible: () => ({ model: withForm(model, (form) => ({ ...form, isTokenVisible: !form.isTokenVisible })), }), // `setCreatedWebhook(null); setIsExpanded(false); form.reset()` - ClickedDismissToken: () => ({ model: withForm(model, () => emptyForm) }), + ClickedDismissToken: () => ({ model: withForm(model, () => emptyCreateForm) }), }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/update.ts b/apps/web-foldkit/src/page/channel-settings/integrations/update.ts index 807c3e553..03686662c 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/update.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/update.ts @@ -1,54 +1,52 @@ -import { Array, Option } from "effect" +import { Array, Option, Result } from "effect" import { Command, Update } from "foldkit" +import * as AsyncData from "foldkit/asyncData" import { modifyFields } from "foldkit/struct" -import type { ToastRequest } from "../../../overlay/toasts" +import { failureMessage } from "~/lib/error-messages" +import { errorToast, errorToastOf, successToast } from "../../../data/actions" import { AppRoute, type RouteOf } from "../../../route" import * as Menu from "../../../ui/menu" -import * as Modal from "../../../ui/modal" -import { errorToast, successToast } from "../../../data/actions" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage, requestedOrgNavigation } from "../../out-message" +import * as ConfirmAction from "../../shared/confirm-action" import { CopyText, ListRss, ListWebhooks, RunRowAction, WaitForCopiedReset } from "./command" import { Message } from "./message" -import { type ConfirmTarget, type Model, type ProviderCard, type RowKind, rowMenuId } from "./model" +import { + CardAction, + type ChannelRequestFailure, + type ConfirmTarget, + emptyCreateForm, + GitHubRepos, + itemsOf, + type Model, + type ProviderCard, + type RowActionFailure, + type RowKind, + RssFeeds, + rowMenuId, + Webhooks, +} from "./model" import { reflectRowMenus, reload } from "./rows" import { updateCards } from "./update-cards" export type Return = PageReturn<Model, Message> -const idleCard: ProviderCard = { - isCreating: false, - isToggling: false, - isDeleting: false, - confirmDelete: false, - confirmVersion: 0, - createdToken: null, -} +const idleCard: ProviderCard = { action: CardAction.Idle(), confirmVersion: 0, createdToken: null } export const init = (route: RouteOf<"ChannelSettingsIntegrations">): Return => ({ model: { channelId: route.channelId, isGitHubConnected: false, - webhooks: { isLoading: true, version: 1, items: [] }, - rss: { isLoading: true, version: 1, items: [] }, - // `useEffect`: not loading until the GitHub connection is known to exist. - github: { isLoading: false, version: 0, items: [] }, + webhooks: { version: 1, data: Webhooks.Loading() }, + rss: { version: 1, data: RssFeeds.Loading() }, + // `useEffect`: nothing to load until the GitHub connection is known to exist. + github: { version: 0, data: GitHubRepos.Idle() }, rowMenus: [], togglingRowIds: [], copiedIds: [], - confirm: { _tag: "Closed" }, - confirmModal: Modal.init("integration-row-remove"), + remove: ConfirmAction.init("integration-row-remove"), providers: { openstatus: idleCard, railway: idleCard }, - createForm: { - isExpanded: false, - name: "", - description: "", - avatarUrl: "", - isNameDirty: false, - isSubmitting: false, - created: null, - isTokenVisible: false, - }, + createForm: emptyCreateForm, }, // Mount effects: `fetchWebhooks()` in the page, `fetchSubscriptions()` in the RSS card. commands: [ @@ -57,33 +55,37 @@ export const init = (route: RouteOf<"ChannelSettingsIntegrations">): Return => ( ], }) -export const toast = (request: ToastRequest) => PageOutMessage.RequestedToast({ toast: request }) +/** A list result lands only if it answers the list's latest request; a failure keeps loaded rows (`Stale`). */ +const settleList = <A>( + list: { + readonly version: number + readonly data: AsyncData.AsyncData<ReadonlyArray<A>, ChannelRequestFailure> + }, + version: number, + result: Result.Result<ReadonlyArray<A>, ChannelRequestFailure>, +) => (list.version === version ? { version, data: AsyncData.settle(list.data, result) } : list) -/** The list a row belongs to reloads with its spinner, like `onUpdate` / `onDelete` refetches. */ -const confirmModal = { - read: (model: Model) => Option.some(model.confirmModal), - // Dismissing the dialog drops a target still being asked about; a running removal keeps going. - write: (model: Model, modal: Modal.Model): Model => - modifyFields(model, { - confirmModal: () => modal, - confirm: (confirm) => - !modal.isOpen && confirm._tag === "Confirming" ? { _tag: "Closed" as const } : confirm, - }), - toParentMessage: (message: Modal.Message) => Message.GotConfirmModalMessage({ message }), +const settleListOf = (model: Model, kind: RowKind, version: number, reason: ChannelRequestFailure): Model => { + const failure = Result.fail(reason) + if (kind === "webhook") + return modifyFields(model, { webhooks: (list) => settleList(list, version, failure) }) + if (kind === "rss") return modifyFields(model, { rss: (list) => settleList(list, version, failure) }) + return modifyFields(model, { github: (list) => settleList(list, version, failure) }) } -const foldConfirmModal = Update.foldChild({ update: Modal.update, ...confirmModal }) -const openConfirmModal = Update.foldChildStep({ update: Modal.open, ...confirmModal }) -const closeConfirmModal = Update.foldChildStep({ update: Modal.close, ...confirmModal }) -const withCommands = (result: Return, commands: Return["commands"]): Return => ({ - ...result, - commands: [...(result.commands ?? []), ...(commands ?? [])], +const removeConfirm = Update.foldChild({ + update: (remove: ConfirmAction.Model<ConfirmTarget>, message: ConfirmAction.Message) => + ConfirmAction.update(remove, message), + read: (model: Model) => Option.some(model.remove), + write: (model: Model, remove: ConfirmAction.Model<ConfirmTarget>): Model => + modifyFields(model, { remove: () => remove }), + toParentMessage: (message: ConfirmAction.Message) => Message.GotRemoveConfirmMessage({ message }), }) const rowEnabled = (model: Model, kind: RowKind, id: string): boolean => { - if (kind === "webhook") return model.webhooks.items.find((row) => row.id === id)?.isEnabled ?? false - if (kind === "rss") return model.rss.items.find((row) => row.id === id)?.isEnabled ?? false - return model.github.items.find((row) => row.id === id)?.isEnabled ?? false + if (kind === "webhook") return itemsOf(model.webhooks).find((row) => row.id === id)?.isEnabled ?? false + if (kind === "rss") return itemsOf(model.rss).find((row) => row.id === id)?.isEnabled ?? false + return itemsOf(model.github).find((row) => row.id === id)?.isEnabled ?? false } const selectedRowItem = (model: Model, kind: RowKind, id: string, key: string): Return => { @@ -92,12 +94,8 @@ const selectedRowItem = (model: Model, kind: RowKind, id: string, key: string): model: reflectRowMenus(modifyFields(model, { togglingRowIds: (ids) => [...ids, id] })), commands: [RunRowAction({ kind, id, isEnabled: !rowEnabled(model, kind, id) })], } - if (key === "remove") { - const target: ConfirmTarget = { kind, id } - return openConfirmModal( - modifyFields(model, { confirm: () => ({ _tag: "Confirming" as const, target }) }), - ) - } + if (key === "remove") + return { model: modifyFields(model, { remove: (remove) => ConfirmAction.ask(remove, { kind, id }) }) } // "edit" opens EditGitHubSubscriptionModal, which is not ported yet. return { model } } @@ -119,22 +117,41 @@ const foldRowMenu = (model: Model, kind: RowKind, id: string, message: Menu.Mess return { ...selected, commands: [...commands, ...(selected.commands ?? [])] } } -const settleRowAction = (model: Model, kind: RowKind, id: string): Return => { +const isRemoving = (model: Model, kind: RowKind, id: string): boolean => + ConfirmAction.isRunning(model.remove) && + Option.exists(ConfirmAction.targetOf(model.remove), (target) => target.kind === kind && target.id === id) + +/** + * The row's action settled: its toggle stops running, and a removal closes the dialog on success + * (even if Escape closed it already) or goes back to asking on failure. + */ +const settleRowAction = (model: Model, kind: RowKind, id: string, isSuccess: boolean): Model => { const settled = reflectRowMenus( modifyFields(model, { togglingRowIds: (ids) => ids.filter((candidate) => candidate !== id) }), ) - const isRemoval = - model.confirm._tag === "Removing" && - model.confirm.target.kind === kind && - model.confirm.target.id === id - // `setShowDeleteDialog(false)` after the delete, whatever its outcome, even if Escape closed it already. - return isRemoval - ? closeConfirmModal(modifyFields(settled, { confirm: () => ({ _tag: "Closed" as const }) })) - : { model: settled } + if (!isRemoving(model, kind, id)) return settled + return modifyFields(settled, { + remove: (remove) => (isSuccess ? ConfirmAction.close(remove) : ConfirmAction.failed(remove)), + }) } -/** A list result lands only if it answers the list's latest request. */ -const isCurrent = (list: { readonly version: number }, version: number) => list.version === version +const ROW_NOUNS: Readonly<Record<RowKind, { readonly title: string; readonly noun: string }>> = { + webhook: { title: "Webhook not found", noun: "webhook" }, + rss: { title: "Subscription not found", noun: "subscription" }, + github: { title: "Subscription not found", noun: "subscription" }, +} + +const rowActionFailureMessage = (kind: RowKind, isToggle: boolean, reason: RowActionFailure) => { + const { title, noun } = ROW_NOUNS[kind] + return failureMessage(reason, { + NotFound: { + title, + description: isToggle + ? `This ${noun} may have been deleted.` + : `This ${noun} may have already been deleted.`, + }, + }) +} export const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { @@ -143,47 +160,32 @@ export const update = (model: Model, message: Message, shared: Shared): Return = const next = modifyFields(model, { isGitHubConnected: () => isConnected }) return isConnected ? reload(next, "github") - : { + : // The card hides the list; a request in flight is dropped by the version bump. + { model: modifyFields(next, { - github: (list) => modifyFields(list, { isLoading: () => false }), + github: (list) => ({ version: list.version + 1, data: GitHubRepos.Idle() }), }), } }, - SucceededListWebhooks: ({ version, webhooks }) => - isCurrent(model.webhooks, version) - ? { - model: reflectRowMenus( - modifyFields(model, { - webhooks: () => ({ isLoading: false, version, items: webhooks }), - }), - ), - } - : { model }, - SucceededListRss: ({ version, feeds }) => - isCurrent(model.rss, version) - ? { - model: reflectRowMenus( - modifyFields(model, { rss: () => ({ isLoading: false, version, items: feeds }) }), - ), - } - : { model }, - SucceededListGitHub: ({ version, repos }) => - isCurrent(model.github, version) - ? { - model: reflectRowMenus( - modifyFields(model, { - github: () => ({ isLoading: false, version, items: repos }), - }), - ), - } - : { model }, - FailedList: ({ list, version, toast: request }) => - isCurrent(model[list], version) - ? { - model: { ...model, [list]: { ...model[list], isLoading: false } }, - outMessage: toast(request), - } - : { model }, + SucceededListWebhooks: ({ version, webhooks }) => ({ + model: reflectRowMenus( + modifyFields(model, { + webhooks: (list) => settleList(list, version, Result.succeed(webhooks)), + }), + ), + }), + SucceededListRss: ({ version, feeds }) => ({ + model: reflectRowMenus( + modifyFields(model, { rss: (list) => settleList(list, version, Result.succeed(feeds)) }), + ), + }), + SucceededListGitHub: ({ version, repos }) => ({ + model: reflectRowMenus( + modifyFields(model, { github: (list) => settleList(list, version, Result.succeed(repos)) }), + ), + }), + FailedList: ({ kind, version, reason }) => ({ model: settleListOf(model, kind, version, reason) }), + ClickedRetryList: ({ kind }) => reload(model, kind), // `window.location.href = /$orgSlug/settings/integrations/github` ClickedConnectGitHub: () => ({ model, @@ -201,39 +203,35 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ClickedAddFeed: () => ({ model }), GotRowMenuMessage: ({ rowMenuId: { kind, id }, message: menuMessage }) => foldRowMenu(model, kind, id, menuMessage), - SucceededRowAction: ({ kind, id, successMessage }) => { - const settled = settleRowAction(model, kind, id) - return { - ...withCommands(reload(settled.model, kind), settled.commands), - outMessage: toast(successToast(successMessage)), - } - }, - FailedRowAction: ({ kind, id, toast: request }) => ({ - ...settleRowAction(model, kind, id), - outMessage: toast(request), + SucceededRowAction: ({ kind, id, successMessage }) => ({ + ...reload(settleRowAction(model, kind, id, true), kind), + outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), }), - ClickedConfirmRemove: () => { - const confirm = model.confirm - if (confirm._tag !== "Confirming") return { model } - return { - model: modifyFields(model, { - confirm: () => ({ _tag: "Removing" as const, target: confirm.target }), + FailedRowAction: ({ kind, id, reason }) => ({ + model: settleRowAction(model, kind, id, false), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf(rowActionFailureMessage(kind, model.togglingRowIds.includes(id), reason)), + }), + }), + ClickedConfirmRemove: () => + Option.match(ConfirmAction.confirm(model.remove), { + onNone: () => ({ model }), + onSome: ([remove, target]) => ({ + model: modifyFields(model, { remove: () => remove }), + commands: [RunRowAction({ ...target, isEnabled: null })], }), - commands: [RunRowAction({ ...confirm.target, isEnabled: null })], - } - }, - GotConfirmModalMessage: ({ message: modalMessage }) => foldConfirmModal(model, modalMessage), + }), + GotRemoveConfirmMessage: ({ message: confirmMessage }) => removeConfirm(model, confirmMessage), ClickedCopy: (copy) => ({ model, commands: [CopyText(copy)] }), - CompletedCopy: ({ id, isCopied, toastTitle }) => - isCopied - ? { - model: modifyFields(model, { - copiedIds: (ids) => [...ids.filter((found) => found !== id), id], - }), - commands: [WaitForCopiedReset({ id })], - outMessage: toast(successToast(toastTitle)), - } - : { model, outMessage: toast(errorToast(toastTitle)) }, + SucceededCopy: ({ id, successMessage }) => ({ + model: modifyFields(model, { copiedIds: (ids) => [...ids.filter((found) => found !== id), id] }), + commands: [WaitForCopiedReset({ id })], + outMessage: PageOutMessage.RequestedToast({ toast: successToast(successMessage) }), + }), + FailedCopy: ({ failureMessage: title }) => ({ + model, + outMessage: PageOutMessage.RequestedToast({ toast: errorToast(title) }), + }), ElapsedCopiedDelay: ({ id }) => ({ model: modifyFields(model, { copiedIds: (ids) => ids.filter((found) => found !== id) }), }), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/view.ts b/apps/web-foldkit/src/page/channel-settings/integrations/view.ts index 4032c44ed..1cbba0fb7 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/view.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/view.ts @@ -1,3 +1,4 @@ +import { Option } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { IconPlus } from "../../../icons" @@ -5,17 +6,19 @@ import { button } from "../../../ui/button" import { dialogClose, dialogFooter, dialogHeader } from "../../../ui/dialog" import * as Modal from "../../../ui/modal" import type { PageViewInputs } from "../../contract" +import * as ConfirmAction from "../../shared/confirm-action" import { tabHeader } from "../section-header" import { gitHubCard } from "./cards" import { rssCard } from "./rss-card" import { Message } from "./message" -import type { Model, RowKind } from "./model" +import { itemsOf, type Model, type RowKind } from "./model" import { providerCard } from "./provider-card" import { customWebhooksCard } from "./webhooks" /** Port of `channels/$channelId/settings/integrations.tsx`. */ -const toConfirmModalMessage = (message: Modal.Message) => Message.GotConfirmModalMessage({ message }) +const toConfirmModalMessage = (message: Modal.Message) => + Message.GotRemoveConfirmMessage({ message: ConfirmAction.toModalMessage(message) }) const CONFIRM_COPY: Readonly<Record<RowKind, { title: string; description: string; action: string }>> = { webhook: { @@ -37,10 +40,14 @@ const CONFIRM_COPY: Readonly<Record<RowKind, { title: string; description: strin /** Each row's `<ModalContent role="alertdialog" size="xs">` delete confirmation. */ const confirmModal = (h: HtmlBuilder<Message>, model: Model): Html => { - const copy = CONFIRM_COPY[model.confirm._tag === "Closed" ? "webhook" : model.confirm.target.kind] + const kind = Option.match(ConfirmAction.targetOf(model.remove), { + onNone: (): RowKind => "webhook", + onSome: (target) => target.kind, + }) + const copy = CONFIRM_COPY[kind] return h.submodel({ slotId: "integration-row-remove", - model: model.confirmModal, + model: model.remove.modal, view: Modal.view, viewInputs: { toTrigger: (_attributes, overlay) => overlay, @@ -48,7 +55,7 @@ const confirmModal = (h: HtmlBuilder<Message>, model: Model): Html => { size: "xs", toContent: (closeAttributes) => [ dialogHeader(h, { - title: { id: Modal.titleId(model.confirmModal.id), text: copy.title }, + title: { id: Modal.titleId(model.remove.modal.id), text: copy.title }, description: copy.description, }), dialogFooter(h, [ @@ -57,7 +64,7 @@ const confirmModal = (h: HtmlBuilder<Message>, model: Model): Html => { h, { intent: "danger", - isPending: model.confirm._tag === "Removing", + isPending: ConfirmAction.isRunning(model.remove), onPress: Message.ClickedConfirmRemove(), }, [copy.action], @@ -92,7 +99,7 @@ const comingSoon = (h: HtmlBuilder<Message>): Html => ) export const view = Submodel.defineView<Model, Message, PageViewInputs>((model, { shared }, h) => { - const regular = model.webhooks.items.filter( + const regular = itemsOf(model.webhooks).filter( (webhook) => webhook.name !== "OpenStatus" && webhook.name !== "Railway", ) return h.div( diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts index ae610aeb2..865e4a465 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/webhooks.ts @@ -5,8 +5,9 @@ import { button } from "../../../ui/button" import { loader } from "../../../ui/loader" import { textField } from "../../../ui/text-field" import type { Shared } from "../../contract" +import { listBody } from "./cards" import { Message } from "./message" -import { type CreateForm, isNameValid, type Model, rowMenuId, type Webhook } from "./model" +import { type CreateForm, type Model, rowMenuId, type Webhook } from "./model" import { formatDistanceToNow } from "./relative-time" import { rowMenu } from "./row-menu" import { tokenDisplay } from "./token-display" @@ -118,7 +119,7 @@ const createWebhookForm = (h: HtmlBuilder<Message>, model: Model, form: CreateFo IconPlus(h, { attributes: { "data-slot": "icon" } }), "Create webhook", ]) - const isNameInvalid = form.isNameDirty && !isNameValid(form.name) + const nameError = form.name._tag === "Invalid" ? form.name.errors[0] : null return h.div( [h.Class("rounded-xl border border-border bg-bg p-4")], [ @@ -127,17 +128,17 @@ const createWebhookForm = (h: HtmlBuilder<Message>, model: Model, form: CreateFo [ textField( h, - { id: "webhook-name", value: form.name, onInput: toField("name") }, + { + id: "webhook-name", + value: form.name.value, + onInput: toField("name"), + isInvalid: nameError !== null, + }, (field) => [ field.label(["Name"]), field.description(["A display name for this webhook"]), - field.input({ - placeholder: "My Webhook", - attributes: [ - h.AriaInvalid(isNameInvalid), - ...(isNameInvalid ? [h.DataAttribute("invalid", "true")] : []), - ], - }), + field.input({ placeholder: "My Webhook" }), + field.fieldError([nameError ?? ""]), ], ), textField( @@ -232,24 +233,27 @@ export const customWebhooksCard = ( h.div( [h.Class("p-4")], [ - model.webhooks.isLoading - ? h.div( + listBody(h, "webhook", model.webhooks.data, { + loading: () => + h.div( [h.Class("flex items-center justify-center py-6")], [loader(h, { className: "size-5" })], - ) - : regular.length === 0 - ? h.div( - [ - h.Class( - "mb-4 rounded-lg border border-border border-dashed py-6 text-center", - ), - ], - [h.p([h.Class("text-muted-fg text-sm")], ["No webhooks yet"])], - ) - : h.div( - [h.Class("mb-4 flex flex-col gap-2")], - regular.map((webhook) => compactWebhookItem(h, model, webhook, shared)), - ), + ), + empty: () => + h.div( + [ + h.Class( + "mb-4 rounded-lg border border-border border-dashed py-6 text-center", + ), + ], + [h.p([h.Class("text-muted-fg text-sm")], ["No webhooks yet"])], + ), + rows: () => + h.div( + [h.Class("mb-4 flex flex-col gap-2")], + regular.map((webhook) => compactWebhookItem(h, model, webhook, shared)), + ), + }), createWebhookForm(h, model, model.createForm), ], ), diff --git a/apps/web-foldkit/src/page/channel-settings/overview/message.ts b/apps/web-foldkit/src/page/channel-settings/overview/message.ts index a7cf89b40..c185e5d60 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/message.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/message.ts @@ -1,8 +1,8 @@ import { ChannelId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" -import { ToastRequest } from "../../../overlay/toasts" import * as Interaction from "../../../ui/aria/interaction" +import { UpdateChannelFailure } from "./model" export const LoadedChannel = Schema.Struct({ id: ChannelId, @@ -16,7 +16,7 @@ export const Message = defineMessageUnion({ ClearedIcon: {}, SubmittedForm: {}, SucceededUpdateChannel: {}, - FailedUpdateChannel: { toast: ToastRequest }, + FailedUpdateChannel: { reason: UpdateChannelFailure }, GotInteractionMessage: { message: Interaction.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/channel-settings/overview/model.ts b/apps/web-foldkit/src/page/channel-settings/overview/model.ts index 0b51f57bb..0d6466598 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/model.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/model.ts @@ -1,17 +1,30 @@ import { ChannelId } from "@hazel/schema" import { Schema } from "effect" +import { Field, isInvalid } from "foldkit/fieldValidation" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as Interaction from "../../../ui/aria/interaction" -/** The ChannelSettingsForm state (TanStack Form plus the icon `useState`s). */ +/** What can go wrong saving the channel, beyond the shared reasons. */ +export const UpdateChannelFailure = Schema.Union([Schema.Literals(["NotFound"]), CommonReason]) +export type UpdateChannelFailure = typeof UpdateChannelFailure.Type + +/** The save request: `Submitting` holds the values sent, which become the saved values on success. */ +export const Submission = defineTaggedUnion({ + NotSubmitted: {}, + Submitting: { name: Schema.String, icon: Schema.NullOr(Schema.String) }, + Failed: { reason: UpdateChannelFailure }, +}) +export type Submission = typeof Submission.Type + +/** The channel settings form: the saved values, the edited ones, and the save request. */ export const Form = Schema.Struct({ - initialName: Schema.String, - initialIcon: Schema.NullOr(Schema.String), - name: Schema.String, + savedName: Schema.String, + savedIcon: Schema.NullOr(Schema.String), + /** `NotValidated` until the user edits the name. */ + name: Field(Schema.String), icon: Schema.NullOr(Schema.String), - /** TanStack Form `isDirty` stays true once the field changed. */ - isNameDirty: Schema.Boolean, - isIconDirty: Schema.Boolean, - isSubmitting: Schema.Boolean, + submission: Submission, }) export type Form = typeof Form.Type @@ -23,13 +36,12 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type -/** arktype `name: "1<string<101"` (exclusive bounds). */ -export const isNameValid = (name: string) => name.length > 1 && name.length < 101 +export const isDirty = (form: Form): boolean => + form.name.value !== form.savedName || form.icon !== form.savedIcon + +export const isSubmitting = (form: Form): boolean => form.submission._tag === "Submitting" -export const canSave = (form: Form) => - !(form.isNameDirty && !isNameValid(form.name)) && - !form.isSubmitting && - (form.isNameDirty || form.isIconDirty) +export const canSave = (form: Form): boolean => isDirty(form) && !isInvalid(form.name) && !isSubmitting(form) export const nameInputTarget = "channel-name-input" export const saveButtonTarget = "channel-save" diff --git a/apps/web-foldkit/src/page/channel-settings/overview/scene.test.ts b/apps/web-foldkit/src/page/channel-settings/overview/scene.test.ts index 167437f2d..318eac720 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/scene.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/scene.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" import { describe, test } from "vitest" -import { errorToast, successToast } from "../../../data/actions" +import { successToast } from "../../../data/actions" import { channelId, failureToastFixture, makeShared, pageScene } from "../../../test/pages-fixtures" import { PageOutMessage } from "../../out-message" import { Message } from "./message" @@ -45,6 +45,7 @@ describe("channel overview scene", () => { PageOutMessage.RequestedToast({ toast: successToast("Channel updated successfully") }), ), Scene.expect(nameInput).toHaveValue("announcements"), + Scene.expect(save).toBeDisabled(), ) }) @@ -55,12 +56,7 @@ describe("channel overview scene", () => { Scene.Subscription.emit(loadedChannel), Scene.type(nameInput, "announcements"), Scene.submit(Scene.selector("form")), - Scene.Command.resolve( - UpdateChannel, - Message.FailedUpdateChannel({ - toast: errorToast(failureToastFixture.title, failureToastFixture.description), - }), - ), + Scene.Command.resolve(UpdateChannel, Message.FailedUpdateChannel({ reason: "Unexpected" })), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: failureToastFixture })), Scene.expect(save).toBeEnabled(), ) @@ -73,6 +69,7 @@ describe("channel overview scene", () => { Scene.Subscription.emit(loadedChannel), Scene.type(nameInput, "a"), Scene.expect(nameInput).toHaveAttr("aria-invalid", "true"), + Scene.expect(Scene.text("Channel name must be at least 2 characters")).toExist(), Scene.expect(save).toBeDisabled(), ) }) diff --git a/apps/web-foldkit/src/page/channel-settings/overview/story.test.ts b/apps/web-foldkit/src/page/channel-settings/overview/story.test.ts index c91f926e2..52635aa86 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/story.test.ts @@ -1,12 +1,11 @@ -import "../document-stub" +import "../../../test/document-stub" import { ChannelId } from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { errorToast } from "../../../data/actions" import { PageOutMessage } from "../../out-message" import { Message } from "./message" -import { canSave } from "./model" +import { canSave, isDirty, isSubmitting } from "./model" import { init, update, UpdateChannel } from "./update" const channelId = Schema.decodeSync(ChannelId)("00000000-0000-4000-8000-000000000001") @@ -35,15 +34,16 @@ describe("channel settings overview", () => { given(loaded), message(Message.ClearedIcon()), message(Message.SubmittedForm()), - model((current) => expect(current.form?.isSubmitting).toBe(true)), - Command.resolve( - UpdateChannel, - Message.FailedUpdateChannel({ toast: errorToast("Channel not found", "Gone.") }), - ), - model((current) => expect(current.form?.isSubmitting).toBe(false)), + model((current) => expect(current.form !== null && isSubmitting(current.form)).toBe(true)), + Command.resolve(UpdateChannel, Message.FailedUpdateChannel({ reason: "NotFound" })), + model((current) => expect(current.form !== null && isSubmitting(current.form)).toBe(false)), expectOutMessage( PageOutMessage.RequestedToast({ - toast: { intent: "error", title: "Channel not found", description: "Gone." }, + toast: { + intent: "error", + title: "Channel not found", + description: "This channel may have been deleted.", + }, }), ), ) @@ -55,7 +55,7 @@ describe("channel settings overview", () => { edited, Message.UpdatedChannel({ channel: { id: channelId, name: "general", icon: null } }), ).model - expect(next.form?.name).toBe("draft") + expect(next.form?.name.value).toBe("draft") }) }) @@ -67,18 +67,13 @@ describe("save failure", () => { message(Message.ChangedName({ name: "general-renamed" })), message(Message.SubmittedForm()), Command.expectExact(UpdateChannel({ id: channelId, name: "general-renamed", icon: "🎉" })), - Command.resolve( - UpdateChannel, - Message.FailedUpdateChannel({ - toast: errorToast("Channel not found", "This channel may have been deleted."), - }), - ), + Command.resolve(UpdateChannel, Message.FailedUpdateChannel({ reason: "Offline" })), expectOutMessage( PageOutMessage.RequestedToast({ toast: { intent: "error", - title: "Channel not found", - description: "This channel may have been deleted.", + title: "Connection lost", + description: "Check your internet connection and try again.", }, }), ), @@ -105,7 +100,7 @@ describe("validation and guards", () => { message(Message.ChangedName({ name: "a" })), message(Message.SubmittedForm()), Command.expectNone(), - model((current) => expect(current.form?.isSubmitting).toBe(false)), + model((current) => expect(current.form !== null && isSubmitting(current.form)).toBe(false)), ) }) @@ -126,7 +121,7 @@ describe("validation and guards", () => { update, given(noIcon), message(Message.ClearedIcon()), - model((current) => expect(current.form?.isIconDirty).toBe(false)), + model((current) => expect(current.form !== null && isDirty(current.form)).toBe(false)), message(Message.SubmittedForm()), Command.expectNone(), ) @@ -148,12 +143,48 @@ describe("save success and channel removal", () => { }), ), model((current) => { - expect(current.form?.isSubmitting).toBe(false) - expect(current.form?.isIconDirty).toBe(false) + expect(current.form !== null && isSubmitting(current.form)).toBe(false) + expect(current.form !== null && isDirty(current.form)).toBe(false) }), ) }) + test("a successful rename becomes the saved name: Save is disabled until the next edit", () => { + story( + update, + given(loaded), + message(Message.ChangedName({ name: "general-renamed" })), + message(Message.SubmittedForm()), + Command.resolve(UpdateChannel, Message.SucceededUpdateChannel()), + model((current) => { + expect(current.form?.savedName).toBe("general-renamed") + expect(current.form !== null && canSave(current.form)).toBe(false) + }), + message(Message.ChangedName({ name: "general" })), + model((current) => expect(current.form !== null && canSave(current.form)).toBe(true)), + ) + }) + + test("the saved values are the ones sent, not edits made while saving", () => { + const saving = update( + update(loaded, Message.ChangedName({ name: "sent" })).model, + Message.SubmittedForm(), + ).model + const edited = update(saving, Message.ChangedName({ name: "typed-while-saving" })).model + const saved = update(edited, Message.SucceededUpdateChannel()).model.form + expect(saved?.savedName).toBe("sent") + expect(saved !== null && canSave(saved)).toBe(true) + }) + + test("an invalid name shows the rule's message", () => { + const form = update(loaded, Message.ChangedName({ name: "a" })).model.form + expect(form?.name).toEqual({ + _tag: "Invalid", + value: "a", + errors: ["Channel name must be at least 2 characters"], + }) + }) + test("a deleted channel unmounts the form", () => { story( update, diff --git a/apps/web-foldkit/src/page/channel-settings/overview/subscription.ts b/apps/web-foldkit/src/page/channel-settings/overview/subscription.ts index 83ecaa559..b281ef58b 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/subscription.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/subscription.ts @@ -1,13 +1,12 @@ import { ChannelId } from "@hazel/schema" import { eq } from "@tanstack/db" -import { Option } from "effect" import { Subscription } from "foldkit" import { channelCollection } from "~/db/collections" import { liveQueryStream } from "../../../data/live-query" -import * as Interaction from "../../../ui/aria/interaction" import type { PageSubscriptionInput } from "../../contract" import { Message } from "./message" import type { Model } from "./model" +import { interaction } from "./update" interface ChannelRow { readonly channel: { readonly id: ChannelId; readonly name: string; readonly icon?: string | null } @@ -44,11 +43,4 @@ const own = Subscription.make<PageSubscriptionInput<Model>, Message>()((entry) = ), })) -const toInteractionMessage = (message: Interaction.Message) => Message.GotInteractionMessage({ message }) - -const interaction = Subscription.lift(Interaction.subscriptions)<PageSubscriptionInput<Model>, Message>({ - read: ({ model }) => Option.some(model.interaction), - toParentMessage: toInteractionMessage, -}) - -export const subscriptions = { ...own, ...interaction } +export const subscriptions = { ...own, ...interaction.subscriptions } diff --git a/apps/web-foldkit/src/page/channel-settings/overview/update.ts b/apps/web-foldkit/src/page/channel-settings/overview/update.ts index 923620d8e..37aa5751d 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/update.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/update.ts @@ -1,44 +1,38 @@ import { ChannelIcon, ChannelId } from "@hazel/schema" -import { Effect, Exit, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" +import { NotValidated, validate } from "foldkit/fieldValidation" import { modifyFields } from "foldkit/struct" +import { failureMessage } from "~/lib/error-messages" +import { catchCommon, errorToastOf, successToast } from "../../../data/actions" import { HazelRpc } from "../../../rpc" import type { RouteOf } from "../../../route" import * as Interaction from "../../../ui/aria/interaction" -import { failureToast, successToast } from "../../../data/actions" -import type { PageReturn } from "../../contract" +import type { PageReturn, PageSubscriptionInput } from "../../contract" import { PageOutMessage } from "../../out-message" +import { channelNameRules } from "../name-rules" import { Message } from "./message" -import { canSave, type Form, type Model, saveButtonTarget } from "./model" +import { canSave, type Form, type Model, saveButtonTarget, Submission } from "./model" type Return = PageReturn<Model, Message> // COMMAND -/** `updateChannelMutation` (`channel.update`) with ChannelSettingsForm's toast handlers. */ +/** `channel.update` with the form's name and icon. */ export const UpdateChannel = Command.define("UpdateChannel", { args: { id: ChannelId, name: Schema.String, icon: Schema.NullOr(Schema.String) }, messages: [Message.SucceededUpdateChannel, Message.FailedUpdateChannel], execute: ({ id, name, icon }) => - Effect.gen(function* () { - const client = yield* HazelRpc - const exit = yield* Effect.exit( - client("channel.update", { id, name, icon: icon === null ? null : ChannelIcon.make(icon) }), - ) - return Exit.match(exit, { - onSuccess: () => Message.SucceededUpdateChannel(), - onFailure: (cause) => { - const toast = failureToast(cause, "exitToast", { - ChannelNotFoundError: { - title: "Channel not found", - description: "This channel may have been deleted.", - isRetryable: false, - }, - }) - return Message.FailedUpdateChannel({ toast }) - }, - }) - }), + HazelRpc.use((rpc) => + rpc("channel.update", { id, name, icon: icon === null ? null : ChannelIcon.make(icon) }), + ).pipe( + Effect.as(Message.SucceededUpdateChannel()), + Effect.catchTags({ + ChannelNotFoundError: () => + Effect.succeed(Message.FailedUpdateChannel({ reason: "NotFound" })), + }), + catchCommon((reason) => Message.FailedUpdateChannel({ reason })), + ), }) // INIT @@ -49,22 +43,28 @@ export const init = (route: RouteOf<"ChannelSettingsOverview">): Return => ({ // UPDATE +export const interaction = Interaction.embed<Model, Message, PageSubscriptionInput<Model>>( + (message) => Message.GotInteractionMessage({ message }), + (input) => input.model, +) + const withForm = (model: Model, f: (form: Form) => Form): Model => modifyFields(model, { form: (form) => (form === null ? null : f(form)) }) -const foldInteraction = (model: Model, message: Interaction.Message): Model => - modifyFields(model, { interaction: (interaction) => Interaction.update(interaction, message).model }) - const submit = (model: Model): Return => { const form = model.form if (form === null || !canSave(form)) return { model } + const name = form.name.value // React Aria ends hover and focus on the button once it is disabled while submitting. const settled = modifyFields(model, { - interaction: (interaction) => Interaction.disabledTargets(interaction, [saveButtonTarget]), + interaction: (current) => Interaction.disabledTargets(current, [saveButtonTarget]), }) return { - model: withForm(settled, (current) => ({ ...current, isSubmitting: true })), - commands: [UpdateChannel({ id: model.channelId, name: form.name, icon: form.icon })], + model: withForm(settled, (current) => ({ + ...current, + submission: Submission.Submitting({ name, icon: form.icon }), + })), + commands: [UpdateChannel({ id: model.channelId, name, icon: form.icon })], } } @@ -77,39 +77,49 @@ export const update = (model: Model, message: Message): Return => return { model: modifyFields(model, { form: () => ({ - initialName: channel.name, - initialIcon: channel.icon, - name: channel.name, + savedName: channel.name, + savedIcon: channel.icon, + name: NotValidated({ value: channel.name }), icon: channel.icon, - isNameDirty: false, - isIconDirty: false, - isSubmitting: false, + submission: Submission.NotSubmitted(), }), }), } }, ChangedName: ({ name }) => ({ - model: withForm(model, (form) => ({ ...form, name, isNameDirty: true })), - }), - ClearedIcon: () => ({ - model: withForm(model, (form) => ({ - ...form, - icon: null, - isIconDirty: form.initialIcon !== null, - })), + model: withForm(model, (form) => ({ ...form, name: validate(channelNameRules)(name) })), }), + ClearedIcon: () => ({ model: withForm(model, (form) => ({ ...form, icon: null })) }), SubmittedForm: () => submit(model), SucceededUpdateChannel: () => ({ - model: withForm(model, (form) => ({ ...form, isSubmitting: false, isIconDirty: false })), + // The sent values are now the saved ones, so the form is clean until the next edit. + model: withForm(model, (form) => + form.submission._tag === "Submitting" + ? { + ...form, + savedName: form.submission.name, + savedIcon: form.submission.icon, + submission: Submission.NotSubmitted(), + } + : form, + ), outMessage: PageOutMessage.RequestedToast({ toast: successToast("Channel updated successfully"), }), }), - FailedUpdateChannel: ({ toast }) => ({ - model: withForm(model, (form) => ({ ...form, isSubmitting: false })), - outMessage: PageOutMessage.RequestedToast({ toast }), - }), - GotInteractionMessage: ({ message: interactionMessage }) => ({ - model: foldInteraction(model, interactionMessage), + FailedUpdateChannel: ({ reason }) => ({ + model: withForm(model, (form) => ({ ...form, submission: Submission.Failed({ reason }) })), + outMessage: PageOutMessage.RequestedToast({ + toast: errorToastOf( + failureMessage(reason, { + NotFound: { + title: "Channel not found", + description: "This channel may have been deleted.", + }, + }), + ), + }), }), + GotInteractionMessage: ({ message: interactionMessage }) => + interaction.fold(model, interactionMessage), }) diff --git a/apps/web-foldkit/src/page/channel-settings/overview/view.ts b/apps/web-foldkit/src/page/channel-settings/overview/view.ts index 3057c422a..7ce941ada 100644 --- a/apps/web-foldkit/src/page/channel-settings/overview/view.ts +++ b/apps/web-foldkit/src/page/channel-settings/overview/view.ts @@ -1,18 +1,17 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { IconClose } from "../../../icons" -import type * as Interaction from "../../../ui/aria/interaction" import { button } from "../../../ui/button" import * as Field from "../../../ui/field" import { textField } from "../../../ui/text-field" import type { PageViewInputs } from "../../contract" import { channelIcon, tabHeader } from "../section-header" import { Message } from "./message" -import { canSave, type Form, isNameValid, type Model, nameInputTarget, saveButtonTarget } from "./model" +import { canSave, type Form, isSubmitting, type Model, nameInputTarget, saveButtonTarget } from "./model" +import { interaction } from "./update" /** Port of `channels/$channelId/settings/overview.tsx`. */ -const toInteractionMessage = (message: Interaction.Message) => Message.GotInteractionMessage({ message }) const toChangedName = (name: string) => Message.ChangedName({ name }) const iconField = (h: HtmlBuilder<Message>, form: Form): Html => @@ -58,25 +57,30 @@ const iconField = (h: HtmlBuilder<Message>, form: Form): Html => ) const settingsForm = (h: HtmlBuilder<Message>, model: Model, form: Form): Html => { - const wiring = { model: model.interaction, toParentMessage: toInteractionMessage } + const wiring = interaction.wiring(model) const isEnabled = canSave(form) + const nameError = form.name._tag === "Invalid" ? form.name.errors[0] : null return h.form( [h.Class("flex flex-col gap-6"), h.OnSubmit(Message.SubmittedForm())], [ iconField(h, form), - textField(h, { id: "channel-name", value: form.name, onInput: toChangedName }, (field) => [ - field.label(["Channel name"]), - field.input({ - placeholder: "Channel name", - interaction: { wiring, target: nameInputTarget }, - attributes: [ - h.AriaInvalid(form.isNameDirty && !isNameValid(form.name)), - ...(form.isNameDirty && !isNameValid(form.name) - ? [h.DataAttribute("invalid", "true")] - : []), - ], - }), - ]), + textField( + h, + { + id: "channel-name", + value: form.name.value, + onInput: toChangedName, + isInvalid: nameError !== null, + }, + (field) => [ + field.label(["Channel name"]), + field.input({ + placeholder: "Channel name", + interaction: { wiring, target: nameInputTarget }, + }), + field.fieldError([nameError ?? ""]), + ], + ), h.div( [], [ @@ -88,7 +92,7 @@ const settingsForm = (h: HtmlBuilder<Message>, model: Model, form: Form): Html = interaction: { wiring, target: saveButtonTarget }, attributes: [h.Type("submit")], }, - [form.isSubmitting ? "Saving..." : "Save changes"], + [isSubmitting(form) ? "Saving..." : "Save changes"], ), ], ), diff --git a/apps/web-foldkit/src/page/channel-settings/document-stub.ts b/apps/web-foldkit/src/test/document-stub.ts similarity index 100% rename from apps/web-foldkit/src/page/channel-settings/document-stub.ts rename to apps/web-foldkit/src/test/document-stub.ts From feb6126df4a7919790f87df4233782c081243688 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Thu, 15 Oct 2026 23:59:17 +0200 Subject: [PATCH 45/52] fix(web): typed session/org states, bounded retries, route-based nav; tests for the boot, org-switch and presence P0s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/app/command.ts | 7 +- apps/web-foldkit/src/app/loading.test.ts | 61 +++-- .../src/app/modal-host.scene.test.ts | 31 ++- apps/web-foldkit/src/app/model.ts | 8 +- .../web-foldkit/src/app/notification-sinks.ts | 111 +++++---- .../src/app/out-messages.story.test.ts | 73 +++++- .../web-foldkit/src/app/routing.story.test.ts | 188 +++++++++++---- .../web-foldkit/src/app/session.scene.test.ts | 7 +- apps/web-foldkit/src/app/shell.scene.test.ts | 8 +- apps/web-foldkit/src/app/subscription.ts | 11 +- apps/web-foldkit/src/app/view.ts | 22 +- apps/web-foldkit/src/data/storage.ts | 10 +- apps/web-foldkit/src/entry.ts | 5 +- apps/web-foldkit/src/main.ts | 226 +++++++++++------- .../overlay/command-palette/search-mount.ts | 8 +- .../src/overlay/modal/create-dm.ts | 35 ++- .../src/overlay/modal/create-organization.ts | 33 ++- .../src/overlay/modal/delete-channel.ts | 14 +- .../src/overlay/modal/delete-workspace.ts | 5 +- .../src/overlay/modal/new-channel.ts | 46 +++- apps/web-foldkit/src/page/out-message.ts | 6 +- apps/web-foldkit/src/page/root/index.ts | 5 +- apps/web-foldkit/src/page/root/story.test.ts | 3 +- apps/web-foldkit/src/platform/desktop-auth.ts | 12 +- .../platform/presence/presence.story.test.ts | 5 +- .../src/platform/presence/update.ts | 10 +- apps/web-foldkit/src/platform/updates.ts | 33 ++- apps/web-foldkit/src/redirect.ts | 5 +- apps/web-foldkit/src/route.test.ts | 13 +- apps/web-foldkit/src/rpc.ts | 4 +- apps/web-foldkit/src/shell/app-shell.ts | 105 ++++---- .../web-foldkit/src/shell/channels-sidebar.ts | 15 +- .../channels-sidebar/create-channel-hint.ts | 13 +- .../src/shell/channels-sidebar/menus.ts | 3 +- .../src/shell/channels-sidebar/tree.ts | 6 +- apps/web-foldkit/src/shell/menus.ts | 23 +- apps/web-foldkit/src/shell/notifications.ts | 5 +- .../web-foldkit/src/shell/shell.story.test.ts | 19 +- apps/web-foldkit/src/shell/sidebars.ts | 22 +- apps/web-foldkit/src/shell/update.ts | 21 +- apps/web-foldkit/src/theme.test.ts | 9 +- 41 files changed, 855 insertions(+), 391 deletions(-) diff --git a/apps/web-foldkit/src/app/command.ts b/apps/web-foldkit/src/app/command.ts index 671d55156..1ddeadf27 100644 --- a/apps/web-foldkit/src/app/command.ts +++ b/apps/web-foldkit/src/app/command.ts @@ -62,8 +62,7 @@ export const SaveSoundSettings = Command.define("SaveSoundSettings", { export const DeliverNotifications = Command.define("DeliverNotifications", { args: { ids: Schema.Array(NotificationId) }, messages: [Message.CompletedDeliverNotifications], - execute: ({ ids }) => - deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), + execute: ({ ids }) => deliverNotifications(ids).pipe(Effect.as(Message.CompletedDeliverNotifications())), }) /** How long the app waits before asking again after the API did not answer. */ @@ -71,7 +70,9 @@ export const RETRY_DELAY = Duration.seconds(10) const checkSession = readSession.pipe( Effect.map((auth) => Message.ChangedAuth({ auth })), - Effect.catchTag("SessionCheckError", ({ reason }) => Effect.succeed(Message.FailedCheckSession({ reason }))), + Effect.catchTag("SessionCheckError", ({ reason }) => + Effect.succeed(Message.FailedCheckSession({ reason })), + ), catchCommon((reason) => Message.FailedCheckSession({ reason })), ) 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<Message>) => const config = { update, view: (model: Model, h: Parameters<typeof view>[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 (`<ProgressBar aria-label="Loading">`). */ 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/modal-host.scene.test.ts b/apps/web-foldkit/src/app/modal-host.scene.test.ts index 4c5c2a6b8..ac5932df6 100644 --- a/apps/web-foldkit/src/app/modal-host.scene.test.ts +++ b/apps/web-foldkit/src/app/modal-host.scene.test.ts @@ -1,5 +1,19 @@ // @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" @@ -62,7 +76,10 @@ describe("modal host", () => { 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.WaitForToastLifetime, + Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 }), + ), Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), Mount.expectEnded(Toast.MeasureToast), ) @@ -76,13 +93,19 @@ 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.WaitForToastLifetime, + Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 }), + ), Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), Mount.expectEnded(Toast.MeasureToast), ) diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index 0e000fab1..473b48e74 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -61,7 +61,9 @@ export type OrgState = typeof OrgState.Type 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 }) + return current._tag !== "None" && current.slug === slug + ? current + : OrgState.Loading({ slug, isStalled: false }) } // MODEL @@ -124,7 +126,9 @@ export const organizationOf = (model: Model): Organization | null => export const memberOf = (model: Model): Member | null => Option.getOrNull( - Option.flatMap(loadedOrgOf(model), (org) => Option.flatMapNullishOr(AsyncData.getData(org.member), (m) => m)), + Option.flatMap(loadedOrgOf(model), (org) => + Option.flatMapNullishOr(AsyncData.getData(org.member), (m) => m), + ), ) export const sharedOf = (model: Model): Shared => ({ diff --git a/apps/web-foldkit/src/app/notification-sinks.ts b/apps/web-foldkit/src/app/notification-sinks.ts index 1f9a0677b..a4616444d 100644 --- a/apps/web-foldkit/src/app/notification-sinks.ts +++ b/apps/web-foldkit/src/app/notification-sinks.ts @@ -1,6 +1,11 @@ import { ChannelId, MessageId, type NotificationId, type UserId } from "@hazel/schema" import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect" -import { channelCollection, messageCollection, notificationCollection, userCollection } from "~/db/collections" +import { + channelCollection, + messageCollection, + notificationCollection, + userCollection, +} from "~/db/collections" import { notificationSoundManager } from "~/lib/notification-sound-manager" import { NativeNotificationSink, @@ -47,39 +52,39 @@ export const wireNotificationSinks = ({ Effect.acquireRelease( 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 - }), + 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)), @@ -92,22 +97,24 @@ const decodeChannelId = Schema.decodeUnknownOption(ChannelId) export const deliverNotifications = (ids: ReadonlyArray<NotificationId>) => Effect.flatMap(Clock.currentTimeMillis, (receivedAtMs) => Effect.sync(() => { - const events = ids.flatMap((id): ReadonlyArray<NotificationEvent> => { - 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()) - }), + const events = ids.flatMap((id): ReadonlyArray<NotificationEvent> => { + 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 22599e06d..d2f52243a 100644 --- a/apps/web-foldkit/src/app/out-messages.story.test.ts +++ b/apps/web-foldkit/src/app/out-messages.story.test.ts @@ -18,7 +18,14 @@ 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. */ @@ -116,16 +123,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", entries: userMenuEntries("hazel", ada.id) }))), + 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()), ) }) @@ -133,10 +146,15 @@ 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"])), + 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.WaitForToastLifetime, + Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 }), + ), Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), ) }) @@ -148,7 +166,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)), ) @@ -163,7 +185,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 })), @@ -218,17 +243,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..30c57d79c 100644 --- a/apps/web-foldkit/src/app/shell.scene.test.ts +++ b/apps/web-foldkit/src/app/shell.scene.test.ts @@ -21,7 +21,6 @@ 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 * as CommandMenu from "../ui/command-menu" import { PortalCommandMenu } from "../ui/command-menu-view" @@ -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( @@ -138,7 +137,10 @@ describe("command palette", () => { 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.WaitForToastLifetime, + Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 }), + ), Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), 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 394a8765d..2b711853c 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -1,6 +1,6 @@ import { ChannelId, type NotificationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { eq } from "@tanstack/db" -import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" +import { Clock, Duration, Option, Schema, Stream } from "effect" import { Dom, Subscription } from "foldkit" import { notificationCollection, @@ -67,7 +67,7 @@ const rootSubscriptions = Subscription.make<Model, Message>()((entry) => ({ sessionRejected: entry( { isSignedIn: Schema.Boolean }, { - modelToDependencies: (model) => ({ isSignedIn: authOf(model.session) === "SignedIn" }), + modelToDependencies: (model) => ({ isSignedIn: authOf(model.session) === "SignedIn" }), dependenciesToStream: ({ isSignedIn }) => isSignedIn ? Stream.fromEventListener(window, SESSION_REJECTED_EVENT).pipe( @@ -120,7 +120,9 @@ const rootSubscriptions = Subscription.make<Model, Message>()((entry) => ({ { organizationId: Schema.NullOr(OrganizationId), userId: Schema.NullOr(UserId) }, { modelToDependencies: (model) => ({ - organizationId: Option.getOrNull(Option.map(loadedOrgOf(model), (org) => org.organization.id)), + organizationId: Option.getOrNull( + Option.map(loadedOrgOf(model), (org) => org.organization.id), + ), userId: currentUserOf(model)?.id ?? null, }), dependenciesToStream: ({ organizationId, userId }) => @@ -221,7 +223,8 @@ const shellSubscriptions = Subscription.lift(ShellSubscription.subscriptions)<Mo }) /** `PresenceProvider` runs inside the loaded `$orgSlug` layout, once `user.me` answered. */ -const presenceUserIdOf = (model: Model): UserId | null => (isShellReady(model) ? (currentUserOf(model)?.id ?? null) : null) +const presenceUserIdOf = (model: Model): UserId | null => + isShellReady(model) ? (currentUserOf(model)?.id ?? null) : null const platformSubscriptions = Subscription.lift(Platform.subscriptions)<Model, Message>({ read: (model) => diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index 4e8dd1d17..b8ccc1d39 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -18,7 +18,14 @@ import { type CurrentUser, displayNameOf, type Organization } from "../session" import { orgShell } from "../shell/app-shell" import * as ChannelsSidebar from "../shell/channels-sidebar" import type { ShellContext } from "../shell/context" -import { appError, appLoader, channelSettingsLayout, organizationNotFound, sectionLayout, contentColumnLayout } from "../shell/layouts" +import { + appError, + appLoader, + channelSettingsLayout, + organizationNotFound, + sectionLayout, + contentColumnLayout, +} from "../shell/layouts" import { formatStatusExpirationAt } from "../shell/menus" import * as Shell from "../shell/model" import * as Notifications from "../shell/notifications" @@ -33,7 +40,8 @@ import { type Model, OrgState, resolvedThemeOf, Session, sharedOf } from "./mode const toShellMessage = (message: Shell.Message): Message => ({ _tag: "GotShellMessage", message }) const toChannelsSidebarMessage = (message: ChannelsSidebar.Message) => toShellMessage({ _tag: "GotChannelsSidebarMessage", message }) -const toUserMenuMessage = (message: Menu.Message) => toShellMessage(Shell.Message.GotUserMenuMessage({ message })) +const toUserMenuMessage = (message: Menu.Message) => + toShellMessage(Shell.Message.GotUserMenuMessage({ message })) const toOrgSwitcherMessage = (message: Menu.Message) => toShellMessage(Shell.Message.GotOrgSwitcherMessage({ message })) const toMobileSidebarMessage = (message: ModalKit.Message) => @@ -85,9 +93,12 @@ const sectionBody = (model: Model, h: HtmlBuilder<Message>): Html => { if (section === "Settings") return sectionLayout( h, - hasContentColumn(route) ? contentColumnLayout(h, pageBody(model, h, true)) : pageBody(model, h, true), + hasContentColumn(route) + ? contentColumnLayout(h, pageBody(model, h, true)) + : pageBody(model, h, true), ) - if (section === "MySettings" || section === "Notifications") return sectionLayout(h, pageBody(model, h, true)) + if (section === "MySettings" || section === "Notifications") + return sectionLayout(h, pageBody(model, h, true)) const tab = channelSettingsTabOf(route) if (tab !== undefined && "channelId" in route) return channelSettingsLayout( @@ -98,7 +109,8 @@ const sectionBody = (model: Model, h: HtmlBuilder<Message>): Html => { channel: model.shell.settingsChannel, selectedTab: tab, onSelectTab: (tabRoute) => h.OnClick(Message.ClickedLayoutTab({ route: tabRoute })), - onChangeTab: (toRoute) => h.OnChange((value) => Message.ClickedLayoutTab({ route: toRoute(value) })), + onChangeTab: (toRoute) => + h.OnChange((value) => Message.ClickedLayoutTab({ route: toRoute(value) })), }, pageBody(model, h, true), ) diff --git a/apps/web-foldkit/src/data/storage.ts b/apps/web-foldkit/src/data/storage.ts index 45830acce..0711fb424 100644 --- a/apps/web-foldkit/src/data/storage.ts +++ b/apps/web-foldkit/src/data/storage.ts @@ -20,12 +20,10 @@ export class StorageWriteError extends Schema.TaggedError<StorageWriteError>()(" cause: Schema.Defect(), }) {} -export const writeStored = <S extends Schema.Constraint>( - key: string, - schema: S, - value: S["Type"], -) => - KeyValueStore.KeyValueStore.use((store) => KeyValueStore.toSchemaStore(store, schema).set(key, value)).pipe( +export const writeStored = <S extends Schema.Constraint>(key: string, schema: S, value: S["Type"]) => + KeyValueStore.KeyValueStore.use((store) => + KeyValueStore.toSchemaStore(store, schema).set(key, value), + ).pipe( Effect.provide(platformStorageLayer), Effect.mapError((cause) => new StorageWriteError({ key, cause })), ) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index 6f165ec97..c46d04299 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -2,12 +2,14 @@ import "@fontsource/inter/400.css" import "@fontsource/inter/400-italic.css" import "./styles.css" +import { Layer } from "effect" import { Runtime } from "foldkit" import { setSessionBearerProvider } from "~/lib/session-bearer" import { initTauri } from "~/lib/tauri" import { isDesktop } from "./platform/desktop" import { desktopSessionBearer } from "./platform/desktop-auth" import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" +import { NotificationSinks } from "./app/notification-sinks" import { ResourcesLive } from "./rpc" const container = document.getElementById("app")! @@ -21,7 +23,8 @@ const startApplication = () => update, view, subscriptions, - resources: ResourcesLive, + // The sinks own audio and native notifications, so they are built here, in the browser only. + resources: Layer.merge(ResourcesLive, NotificationSinks.layer), container, routing: { onUrlRequest: (request) => Message.ClickedLink({ request }), diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index 7f906ce65..b7ad9807b 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -115,8 +115,10 @@ const setModel = /** Each child folds through one lens; the input is the child entry point to run (open, push, update...). */ const foldToasts = Update.foldChild({ - update: (toasts: Toasts.Model, step: (toasts: Toasts.Model) => Update.Return<Toasts.Model, Toasts.Message>) => - step(toasts), + update: ( + toasts: Toasts.Model, + step: (toasts: Toasts.Model) => Update.Return<Toasts.Model, Toasts.Message>, + ) => step(toasts), read: (model: Model) => Option.some(model.toasts), write: (model, toasts) => modifyFields(model, { toasts: () => toasts }), toParentMessage: (message: Toasts.Message) => Message.GotToastsMessage({ message }), @@ -213,7 +215,10 @@ const foldCommandPalette = Update.foldChild({ CommandPalette.OutMessage.match<Step>(outMessage, { // The palette's links can carry a search (`?messageId=`), so they stay URLs. Completed: ({ href, toast }) => - Update.combine([href === null ? unchanged : command(NavigateInternal({ url: href })), showToastIf(toast)]), + Update.combine([ + href === null ? unchanged : command(NavigateInternal({ url: href })), + showToastIf(toast), + ]), RequestedModal: ({ modal }) => openModal(modal), RequestedToast: ({ toast }) => showToast(toast), RequestedTheme: ({ preference }) => requestedTheme(preference), @@ -258,7 +263,8 @@ const foldPage = Update.foldChild({ const informPage: Step = (model) => foldPage(model, (page) => informShared(page, sharedOf(model))) -const enteredRoute: Step = (model) => foldPage(model, (page) => enterRoute(page, model.route, sharedOf(model))) +const enteredRoute: Step = (model) => + foldPage(model, (page) => enterRoute(page, model.route, sharedOf(model))) /** `useAppHotkey` handlers in `$orgSlug/layout.tsx`. */ const pressedHotkey = (actionId: LayoutHotkeyActionId): Step => @@ -267,8 +273,10 @@ const pressedHotkey = (actionId: LayoutHotkeyActionId): Step => // Legacy passes `initialPage`, but the palette shows its own (reset) page: always home. Match.when("commandPalette.open", () => openCommandPalette("home")), Match.when("search.open", () => openCommandPalette("home")), - Match.when("channel.create", () => (model) => - can(sharedOf(model), "channel.create") ? openModal({ _tag: "NewChannel" })(model) : { model }, + Match.when( + "channel.create", + () => (model) => + can(sharedOf(model), "channel.create") ? openModal({ _tag: "NewChannel" })(model) : { model }, ), Match.when("dm.create", () => openModal({ _tag: "CreateDm" })), Match.when("invite.email", () => openModal({ _tag: "EmailInvite" })), @@ -281,7 +289,9 @@ const pressedDesktopMenuItem = (item: DesktopMenuItem): Step => Match.withReturnType<Step>(), Match.when("OpenSettings", () => (model) => { const orgSlug = orgSlugOf(model.route) - return orgSlug === undefined ? { model } : navigateTo(AppRoute.MySettingsDesktop({ orgSlug }))(model) + return orgSlug === undefined + ? { model } + : navigateTo(AppRoute.MySettingsDesktop({ orgSlug }))(model) }), Match.when("CheckForUpdates", () => stepUpdates(Updates.check)), Match.when("NewChannel", () => pressedHotkey("channel.create")), @@ -343,85 +353,99 @@ const refreshCurrentUser: Step = (model) => const signedInSession = Session.SignedIn({ user: AsyncData.Loading(), retries: 0 }) -const changedAuth = (auth: "SignedIn" | "SignedOut"): Step => (model) => { - if (auth === "SignedOut") - return model.session._tag === "SignedOut" - ? { model } - : sessionChanged(modifyFields(model, { session: () => Session.SignedOut() })) - // A session check that confirms the session changes nothing; a new one loads its user. - if (model.session._tag === "SignedIn") return { model } - return Update.combine(modifyFields(model, { session: () => signedInSession }), [ - sessionChanged, - command(FetchCurrentUser()), - ]) -} +const changedAuth = + (auth: "SignedIn" | "SignedOut"): Step => + (model) => { + if (auth === "SignedOut") + return model.session._tag === "SignedOut" + ? { model } + : sessionChanged(modifyFields(model, { session: () => Session.SignedOut() })) + // A session check that confirms the session changes nothing; a new one loads its user. + if (model.session._tag === "SignedIn") return { model } + return Update.combine(modifyFields(model, { session: () => signedInSession }), [ + sessionChanged, + command(FetchCurrentUser()), + ]) + } /** The API did not answer the session check. A signed-in session stays: only the boot check can stall. */ -const failedCheckSession = (reason: CommonReason): Step => (model) => { - if (model.session._tag === "SignedIn" || model.session._tag === "SignedOut") return { model } - const retries = model.session._tag === "Unreachable" ? model.session.retries : 0 - const isAutoRetried = retries < MAX_AUTO_RETRIES - return { - model: modifyFields(model, { session: () => Session.Unreachable({ reason, retries: retries + 1 }) }), - commands: isAutoRetried ? [RetryCheckSession()] : [], +const failedCheckSession = + (reason: CommonReason): Step => + (model) => { + if (model.session._tag === "SignedIn" || model.session._tag === "SignedOut") return { model } + const retries = model.session._tag === "Unreachable" ? model.session.retries : 0 + const isAutoRetried = retries < MAX_AUTO_RETRIES + return { + model: modifyFields(model, { + session: () => Session.Unreachable({ reason, retries: retries + 1 }), + }), + commands: isAutoRetried ? [RetryCheckSession()] : [], + } } -} /** `user.me` failed. A transient failure is retried a few times; after that, or an ended session, it shows. */ -const failedFetchCurrentUser = (reason: CommonReason): Step => (model) => { - if (model.session._tag !== "SignedIn") return { model } - const { user, retries } = model.session - const hasUser = AsyncData.hasData(user) - if (!hasUser && isRetryableReason(reason) && retries < MAX_AUTO_RETRIES) - return { - model: modifyFields(model, { session: () => Session.SignedIn({ user, retries: retries + 1 }) }), - commands: [RetryFetchCurrentUser()], - } - const failed = Session.SignedIn({ - user: Option.match(AsyncData.getData(user), { - onNone: () => AsyncData.Failure({ error: reason }), - onSome: (data) => AsyncData.Stale({ error: reason, data }), - }), - retries, - }) - // An ended session re-reads it: if it is gone, ChangedAuth sends the browser to sign-in. - return Update.combine(modifyFields(model, { session: () => failed }), [ - sessionChanged, - reason === "SignedOut" ? command(CheckSession()) : unchanged, - ]) -} +const failedFetchCurrentUser = + (reason: CommonReason): Step => + (model) => { + if (model.session._tag !== "SignedIn") return { model } + const { user, retries } = model.session + const hasUser = AsyncData.hasData(user) + if (!hasUser && isRetryableReason(reason) && retries < MAX_AUTO_RETRIES) + return { + model: modifyFields(model, { + session: () => Session.SignedIn({ user, retries: retries + 1 }), + }), + commands: [RetryFetchCurrentUser()], + } + const failed = Session.SignedIn({ + user: Option.match(AsyncData.getData(user), { + onNone: () => AsyncData.Failure({ error: reason }), + onSome: (data) => AsyncData.Stale({ error: reason, data }), + }), + retries, + }) + // An ended session re-reads it: if it is gone, ChangedAuth sends the browser to sign-in. + return Update.combine(modifyFields(model, { session: () => failed }), [ + sessionChanged, + reason === "SignedOut" ? command(CheckSession()) : unchanged, + ]) + } // ORGANIZATION -const updatedOrganization = (orgSlug: string, organization: Organization | null): Step => (model) => { - const current = model.org - if (current._tag === "None" || current.slug !== orgSlug) return { model } - const next: OrgState = - organization === null - ? OrgState.NotFound({ slug: orgSlug }) - : OrgState.Loaded({ - slug: orgSlug, - organization, - // The role belongs to one organization; another one's must not show until its own arrives. - member: - current._tag === "Loaded" && current.organization.id === organization.id - ? current.member - : MemberData.Loading(), - }) - return Update.combine(modifyFields(model, { org: () => next }), [informPage, informShell]) -} +const updatedOrganization = + (orgSlug: string, organization: Organization | null): Step => + (model) => { + const current = model.org + if (current._tag === "None" || current.slug !== orgSlug) return { model } + const next: OrgState = + organization === null + ? OrgState.NotFound({ slug: orgSlug }) + : OrgState.Loaded({ + slug: orgSlug, + organization, + // The role belongs to one organization; another one's must not show until its own arrives. + member: + current._tag === "Loaded" && current.organization.id === organization.id + ? current.member + : MemberData.Loading(), + }) + return Update.combine(modifyFields(model, { org: () => next }), [informPage, informShell]) + } /** The user's row in the loaded organization; a row for another organization is stale and ignored. */ -const updatedMember = (organizationId: OrganizationId, member: Member | null): Step => (model) => { - const current = model.org - if (current._tag !== "Loaded" || current.organization.id !== organizationId) return { model } - return Update.combine( - modifyFields(model, { - org: () => OrgState.Loaded({ ...current, member: MemberData.Success({ data: member }) }), - }), - [informPage, informShell], - ) -} +const updatedMember = + (organizationId: OrganizationId, member: Member | null): Step => + (model) => { + const current = model.org + if (current._tag !== "Loaded" || current.organization.id !== organizationId) return { model } + return Update.combine( + modifyFields(model, { + org: () => OrgState.Loaded({ ...current, member: MemberData.Success({ data: member }) }), + }), + [informPage, informShell], + ) + } // URL @@ -431,7 +455,10 @@ const withUrl = (model: Model, url: Url): Model => { route: () => route, // Foldkit's `Url.search` holds the query without its `?`. currentUrl: () => - Option.match(url.search, { onNone: () => url.pathname, onSome: (search) => `${url.pathname}?${search}` }), + Option.match(url.search, { + onNone: () => url.pathname, + onSome: (search) => `${url.pathname}?${search}`, + }), org: (org) => orgStateFor(org, route), }) } @@ -439,7 +466,9 @@ const withUrl = (model: Model, url: Url): Model => { // THEME const isSameCustomization = (first: ThemeCustomization, second: ThemeCustomization) => - first.primary === second.primary && first.grayPalette === second.grayPalette && first.radius === second.radius + first.primary === second.primary && + first.grayPalette === second.grayPalette && + first.radius === second.radius /** * The legacy theme atoms' effects after a change: re-apply when the resolved mode or the @@ -468,12 +497,13 @@ const transitionTheme = const requestedTheme = (preference: ThemePreference): Step => transitionTheme((model) => modifyFields(model, { themePreference: () => preference }), true) -export const requestTheme = (model: Model, preference: ThemePreference): Return => requestedTheme(preference)(model) +export const requestTheme = (model: Model, preference: ThemePreference): Return => + requestedTheme(preference)(model) const appliedTheme: Step = (model) => - command(ApplyTheme({ resolved: resolvedThemeOf(model), customization: model.themePreference.customization }))( - model, - ) + command( + ApplyTheme({ resolved: resolvedThemeOf(model), customization: model.themePreference.customization }), + )(model) // INIT @@ -538,14 +568,18 @@ export const update = (model: Model, message: Message): Return => FailedCheckSession: ({ reason }) => failedCheckSession(reason)(model), PressedRetrySession: () => model.session._tag === "Unreachable" - ? { model: modifyFields(model, { session: () => Session.Loading() }), commands: [CheckSession()] } + ? { + model: modifyFields(model, { session: () => Session.Loading() }), + commands: [CheckSession()], + } : { model }, RejectedSession: () => ({ model, commands: [CheckSession()] }), SucceededFetchCurrentUser: ({ user }) => model.session._tag === "SignedIn" ? sessionChanged( modifyFields(model, { - session: () => Session.SignedIn({ user: AsyncData.Success({ data: user }), retries: 0 }), + session: () => + Session.SignedIn({ user: AsyncData.Success({ data: user }), retries: 0 }), }), ) : { model }, @@ -554,28 +588,36 @@ export const update = (model: Model, message: Message): Return => // A full load of `/`: every collection, token and subscription starts over, signed out. SucceededSignOut: () => ({ model, commands: [LoadExternal({ href: hrefOf(AppRoute.Root()) })] }), FailedSignOut: ({ reason }) => - showToast(errorToastOf({ title: "Failed to sign out", description: commonMessage(reason).description }))( - model, - ), + showToast( + errorToastOf({ title: "Failed to sign out", description: commonMessage(reason).description }), + )(model), UpdatedOrganization: ({ orgSlug, organization }) => updatedOrganization(orgSlug, organization)(model), // Only a slug still loading counts: data already on screen stays when its sync hiccups. StalledOrganization: ({ orgSlug }) => model.org._tag === "Loading" && model.org.slug === orgSlug && !model.org.isStalled - ? { model: modifyFields(model, { org: () => OrgState.Loading({ slug: orgSlug, isStalled: true }) }) } + ? { + model: modifyFields(model, { + org: () => OrgState.Loading({ slug: orgSlug, isStalled: true }), + }), + } : { model }, PressedReload: () => ({ model, commands: [Reload()] }), CompletedReload: () => ({ model }), UpdatedMember: ({ organizationId, member }) => updatedMember(organizationId, member)(model), TickedClock: ({ nowMs }) => ({ model: modifyFields(model, { nowMs: () => nowMs }) }), ClickedLayoutTab: ({ route }) => navigateTo(route)(model), - GotPageMessage: ({ message }) => foldPage(model, (page) => updatePage(page, message, sharedOf(model))), - GotShellMessage: ({ message }) => foldShell(model, (shell) => Shell.update(shell, message, shellContextOf(model))), - GotModalMessage: ({ message }) => foldModal(model, (modal) => Modal.update(modal, message, modalContextOf(model))), + GotPageMessage: ({ message }) => + foldPage(model, (page) => updatePage(page, message, sharedOf(model))), + GotShellMessage: ({ message }) => + foldShell(model, (shell) => Shell.update(shell, message, shellContextOf(model))), + GotModalMessage: ({ message }) => + foldModal(model, (modal) => Modal.update(modal, message, modalContextOf(model))), GotCommandPaletteMessage: ({ message }) => foldCommandPalette(model, (palette) => CommandPalette.update(palette, message, sharedOf(model))), PressedHotkey: ({ actionId }) => pressedHotkey(actionId)(model), GotToastsMessage: ({ message }) => foldToastsWithAction(model, message), GotUpdatesMessage: ({ message }) => stepUpdates((updates) => Updates.update(updates, message))(model), PressedDesktopMenuItem: ({ item }) => pressedDesktopMenuItem(item)(model), - GotPlatformMessage: ({ message }) => foldPlatform(model, (platform) => Platform.update(platform, message)), + GotPlatformMessage: ({ message }) => + foldPlatform(model, (platform) => Platform.update(platform, message)), }) diff --git a/apps/web-foldkit/src/overlay/command-palette/search-mount.ts b/apps/web-foldkit/src/overlay/command-palette/search-mount.ts index d2b364e72..f1edee7ef 100644 --- a/apps/web-foldkit/src/overlay/command-palette/search-mount.ts +++ b/apps/web-foldkit/src/overlay/command-palette/search-mount.ts @@ -71,7 +71,9 @@ const editorFailed = <M>(toFailure: (failure: { reason: "EditorNotMounted" }) => M) => <A, R>(effect: Effect.Effect<A, EditorNotMounted, R>): Effect.Effect<A | M, never, R> => effect.pipe( - Effect.catchTags({ EditorNotMounted: () => Effect.succeed(toFailure({ reason: "EditorNotMounted" })) }), + Effect.catchTags({ + EditorNotMounted: () => Effect.succeed(toFailure({ reason: "EditorNotMounted" })), + }), ) export const SetSearchText = Command.define("SetSearchText", { @@ -132,7 +134,9 @@ const RecentSearches = Schema.Array(RecentSearch) export const LoadRecentSearches = Command.define("LoadRecentSearches", { messages: [Message.LoadedRecentSearches], execute: readStored(RECENT_SEARCHES_KEY, RecentSearches).pipe( - Effect.map((stored) => Message.LoadedRecentSearches({ searches: Option.getOrElse(stored, () => []) })), + Effect.map((stored) => + Message.LoadedRecentSearches({ searches: Option.getOrElse(stored, () => []) }), + ), ), }) diff --git a/apps/web-foldkit/src/overlay/modal/create-dm.ts b/apps/web-foldkit/src/overlay/modal/create-dm.ts index 8dfddf214..a66bfd9a4 100644 --- a/apps/web-foldkit/src/overlay/modal/create-dm.ts +++ b/apps/web-foldkit/src/overlay/modal/create-dm.ts @@ -44,7 +44,11 @@ const StartConversation = Command.define("StartConversation", { name: name ?? undefined, }) return Message.SucceededCreateDm({ channelId: result.data.id }) - }).pipe(Effect.catchCause((cause) => Effect.succeed(Message.FailedCreateDm({ toast: failureToast(cause, "friendly") })))), + }).pipe( + Effect.catchCause((cause) => + Effect.succeed(Message.FailedCreateDm({ toast: failureToast(cause, "friendly") })), + ), + ), }) type Return = ModalReturn<Model, Message> @@ -54,7 +58,13 @@ const chatRoute = (shared: Shared, channelId: ChannelId) => const submitted = (model: Model, shared: Shared): Return => { const ids = model.selectedUserIds - if (ids.length === 0 || model.isSubmitting || !shared.currentUser || !shared.organization || !shared.orgSlug) + if ( + ids.length === 0 || + model.isSubmitting || + !shared.currentUser || + !shared.organization || + !shared.orgSlug + ) return { model } const type = ids.length === 1 ? "single" : "direct" const names = model.organizationUsers @@ -97,16 +107,25 @@ const update = (model: Model, message: Message, shared: Shared): Return => BlurredSearch: () => ({ model: modifyFields(model, { isSearchFocused: () => false }) }), ClickedUser: ({ userId }) => ({ model: modifyFields(model, { - selectedUserIds: (ids) => (ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId]), + selectedUserIds: (ids) => + ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId], }), }), ClickedCancel: () => ({ model, outMessage: closed }), ClickedStartConversation: () => submitted(model, shared), - UpdatedOrganizationUsers: ({ users }) => ({ model: modifyFields(model, { organizationUsers: () => users }) }), - FoundExistingDm: ({ channelId }) => ({ model, outMessage: completed({ route: chatRoute(shared, channelId) }) }), + UpdatedOrganizationUsers: ({ users }) => ({ + model: modifyFields(model, { organizationUsers: () => users }), + }), + FoundExistingDm: ({ channelId }) => ({ + model, + outMessage: completed({ route: chatRoute(shared, channelId) }), + }), SucceededCreateDm: ({ channelId }) => ({ model, - outMessage: completed({ route: chatRoute(shared, channelId), toast: successToast(successMessage(model)) }), + outMessage: completed({ + route: chatRoute(shared, channelId), + toast: successToast(successMessage(model)), + }), }), FailedCreateDm: ({ toast }) => ({ model: modifyFields(model, { isSubmitting: () => false }), @@ -123,7 +142,9 @@ const subscriptions = Subscription.make<ModalSubscriptionInput<Model>, Message>( dependenciesToStream: ({ organizationId }) => organizationId === null ? Stream.empty - : organizationUsersStream(organizationId, (users) => Message.UpdatedOrganizationUsers({ users })), + : organizationUsersStream(organizationId, (users) => + Message.UpdatedOrganizationUsers({ users }), + ), }, ), })) diff --git a/apps/web-foldkit/src/overlay/modal/create-organization.ts b/apps/web-foldkit/src/overlay/modal/create-organization.ts index 79950abc5..df3f93875 100644 --- a/apps/web-foldkit/src/overlay/modal/create-organization.ts +++ b/apps/web-foldkit/src/overlay/modal/create-organization.ts @@ -14,7 +14,15 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" +import { + Frame, + Message as FrameMessage, + frameView, + initFrame, + isCloseRequest, + modalDescription, + modalTitle, +} from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-organization-modal.tsx` (legacy `useModal("create-organization")`). */ @@ -64,7 +72,8 @@ const CreateOrganization = Command.define("CreateOrganization", { toast: failureToast(cause, "friendly", { OrganizationSlugAlreadyExistsError: { title: "Slug already taken", - description: "That workspace URL is already in use. Please choose a different one.", + description: + "That workspace URL is already in use. Please choose a different one.", isRetryable: false, }, }), @@ -101,7 +110,8 @@ const submitted = (model: Model): Return => { const update = (model: Model, message: Message): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => + isCloseRequest(message) ? { model, outMessage: closed } : { model }, ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value, errors: () => validate(value, model.slug) }), }), @@ -146,7 +156,10 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input () => [ dialogHeader(h, {}, [ modalTitle(h, model.frame, "Create a new Server"), - modalDescription(h, "Give your server a name and optional slug to create a new organization."), + modalDescription( + h, + "Give your server a name and optional slug to create a new organization.", + ), ]), h.form( [h.OnSubmit(Message.SubmittedForm())], @@ -156,7 +169,11 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input [ textField( h, - { id: `${ID}-name`, value: model.name, onInput: (value) => Message.ChangedName({ value }) }, + { + id: `${ID}-name`, + value: model.name, + onInput: (value) => Message.ChangedName({ value }), + }, (field) => [ field.label(["Server Name"]), inputGroup(h, { attributes: [h.Role("presentation")] }, [ @@ -167,7 +184,11 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input ), textField( h, - { id: `${ID}-slug`, value: model.slug, onInput: (value) => Message.ChangedSlug({ value }) }, + { + id: `${ID}-slug`, + value: model.slug, + onInput: (value) => Message.ChangedSlug({ value }), + }, (field) => [ field.label(["Server Slug"]), inputGroup(h, { attributes: [h.Role("presentation")] }, [ diff --git a/apps/web-foldkit/src/overlay/modal/delete-channel.ts b/apps/web-foldkit/src/overlay/modal/delete-channel.ts index 5ea33c336..f3b34e037 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-channel.ts @@ -39,7 +39,8 @@ export const DeleteChannel = Command.define("DeleteChannel", { deleteChannelAction({ channelId }).pipe( Effect.as(Message.SucceededDeleteChannel()), Effect.catchTags({ - ChannelNotFoundError: () => Effect.succeed(Message.FailedDeleteChannel({ reason: "NotFound" })), + ChannelNotFoundError: () => + Effect.succeed(Message.FailedDeleteChannel({ reason: "NotFound" })), }), catchCommon((reason) => Message.FailedDeleteChannel({ reason })), ), @@ -56,13 +57,15 @@ const isChannelRoute = AppRoute.isAnyOf([ "ChannelSettingsConnect", ]) -const isOnChannel = (route: AppRoute, channelId: ChannelId) => isChannelRoute(route) && route.channelId === channelId +const isOnChannel = (route: AppRoute, channelId: ChannelId) => + isChannelRoute(route) && route.channelId === channelId type Return = ModalReturn<Model, Message> const update = (model: Model, message: Message, context: ModalContext): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => + isCloseRequest(message) ? { model, outMessage: closed } : { model }, ClickedCancel: () => ({ model, outMessage: closed }), ClickedDelete: () => ({ model, commands: [DeleteChannel({ channelId: model.channelId })] }), // Leave the deleted channel's pages for the chat index (the route is read when the result lands). @@ -80,7 +83,10 @@ const update = (model: Model, message: Message, context: ModalContext): Return = outMessage: ModalOutMessage.RequestedToast({ toast: errorToastOf( failureMessage(reason, { - NotFound: { title: "Channel not found", description: "This channel may have already been deleted." }, + NotFound: { + title: "Channel not found", + description: "This channel may have already been deleted.", + }, }), ), }), diff --git a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts index e662df82c..9fb2ddedb 100644 --- a/apps/web-foldkit/src/overlay/modal/delete-workspace.ts +++ b/apps/web-foldkit/src/overlay/modal/delete-workspace.ts @@ -90,7 +90,10 @@ const update = (model: Model, message: Message): Return => // The settings page's `onDeleted` navigated to the root. SucceededDeleteWorkspace: () => ({ model, - outMessage: completed({ route: AppRoute.Root(), toast: successToast("Workspace deleted successfully") }), + outMessage: completed({ + route: AppRoute.Root(), + toast: successToast("Workspace deleted successfully"), + }), }), FailedDeleteWorkspace: ({ toast }) => ({ model: modifyFields(model, { isDeleting: () => false }), diff --git a/apps/web-foldkit/src/overlay/modal/new-channel.ts b/apps/web-foldkit/src/overlay/modal/new-channel.ts index 097b21780..68333e791 100644 --- a/apps/web-foldkit/src/overlay/modal/new-channel.ts +++ b/apps/web-foldkit/src/overlay/modal/new-channel.ts @@ -19,7 +19,15 @@ import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" import * as Requests from "./requests" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" -import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalDescription, modalTitle } from "./frame" +import { + Frame, + Message as FrameMessage, + frameView, + initFrame, + isCloseRequest, + modalDescription, + modalTitle, +} from "./frame" import { failureToast, successToast } from "../../data/actions" /** `components/modals/create-channel-modal.tsx` (legacy `useModal("new-channel")`). */ @@ -61,7 +69,9 @@ const CreateChannel = Command.define("CreateChannel", { execute: (args) => createChannelAction({ ...args, icon: null, parentChannelId: null }).pipe( Effect.map((result) => Message.SucceededCreateChannel({ channelId: result.data.channelId })), - Effect.catchCause((cause) => Effect.succeed(Message.FailedCreateChannel({ toast: failureToast(cause, "friendly") }))), + Effect.catchCause((cause) => + Effect.succeed(Message.FailedCreateChannel({ toast: failureToast(cause, "friendly") })), + ), ), }) @@ -78,14 +88,22 @@ type Return = ModalReturn<Model, Message> const submitted = (model: Model, shared: Shared): Return => { const nameError = validateName(model.name) if (nameError !== null) return { model: modifyFields(model, { nameError: () => nameError }) } - if (model.isSubmitting || shared.currentUser === null || shared.organization === null || shared.orgSlug === null) + if ( + model.isSubmitting || + shared.currentUser === null || + shared.organization === null || + shared.orgSlug === null + ) return { model } return { model: modifyFields(model, { isSubmitting: () => true }), commands: [ CreateChannel({ name: model.name, - type: Option.getOrElse(model.channelType.selectedKey, () => "public") === "private" ? "private" : "public", + type: + Option.getOrElse(model.channelType.selectedKey, () => "public") === "private" + ? "private" + : "public", organizationId: shared.organization.id, currentUserId: shared.currentUser.id, addAllMembers: model.addAllMembers, @@ -96,18 +114,23 @@ const submitted = (model: Model, shared: Shared): Return => { const update = (model: Model, message: Message, shared: Shared): Return => Message.match<Return>(message, { - GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => + isCloseRequest(message) ? { model, outMessage: closed } : { model }, GotChannelTypeMessage: ({ message }) => { const result = Select.update(model.channelType, message) return { model: modifyFields(model, { channelType: () => result.model }), - commands: Command.mapMessages(result.commands, (child) => Message.GotChannelTypeMessage({ message: child })), + commands: Command.mapMessages(result.commands, (child) => + Message.GotChannelTypeMessage({ message: child }), + ), } }, ChangedName: ({ value }) => ({ model: modifyFields(model, { name: () => value, nameError: () => validateName(value) }), }), - ToggledAddAllMembers: ({ isSelected }) => ({ model: modifyFields(model, { addAllMembers: () => isSelected }) }), + ToggledAddAllMembers: ({ isSelected }) => ({ + model: modifyFields(model, { addAllMembers: () => isSelected }), + }), ClickedCancel: () => ({ model, outMessage: closed }), SubmittedForm: () => submitted(model, shared), SucceededCreateChannel: ({ channelId }) => ({ @@ -156,8 +179,13 @@ const view = Submodel.defineView<Model, Message, ModalViewInputs>((model, _input field.input({ placeholder: "general", attributes: [ - h.Attribute("aria-invalid", model.nameError === null ? "false" : "true"), - ...(model.nameError === null ? [] : [h.Attribute("data-invalid", "true")]), + h.Attribute( + "aria-invalid", + model.nameError === null ? "false" : "true", + ), + ...(model.nameError === null + ? [] + : [h.Attribute("data-invalid", "true")]), ], }), ]), diff --git a/apps/web-foldkit/src/page/out-message.ts b/apps/web-foldkit/src/page/out-message.ts index cb5a421b3..06c672f9d 100644 --- a/apps/web-foldkit/src/page/out-message.ts +++ b/apps/web-foldkit/src/page/out-message.ts @@ -10,7 +10,11 @@ import { ThemePreference } from "../theme" /** Facts a page (or the shell) reports to the root, which owns navigation and the overlays. */ export const PageOutMessage = defineMessageUnion({ /** `toast` shows with the navigation (legacy `onSuccess: toast + navigate` in one handler). */ - RequestedNavigation: { route: AppRoute, replace: Schema.Boolean, toast: Schema.optionalKey(ToastRequest) }, + RequestedNavigation: { + route: AppRoute, + replace: Schema.Boolean, + toast: Schema.optionalKey(ToastRequest), + }, RequestedToast: { toast: ToastRequest }, RequestedModal: { modal: ModalRequest }, RequestedCommandPalette: { page: CommandPalettePage }, diff --git a/apps/web-foldkit/src/page/root/index.ts b/apps/web-foldkit/src/page/root/index.ts index 549fc67d5..a8fd75327 100644 --- a/apps/web-foldkit/src/page/root/index.ts +++ b/apps/web-foldkit/src/page/root/index.ts @@ -39,7 +39,10 @@ export const ForgetReturnTo = Command.define("ForgetReturnTo", { * Where to go: back to where sign-in started, unless onboarding comes first; else `rootRedirect`. * Before onboarding the remembered page is kept, so `/` after onboarding still goes back to it. */ -const targetOf = (model: Model, user: CurrentUser): { readonly route: AppRoute; readonly isReturn: boolean } => +const targetOf = ( + model: Model, + user: CurrentUser, +): { readonly route: AppRoute; readonly isReturn: boolean } => model.returnTo && user.isOnboarded ? { route: routeOfPath(model.returnTo), isReturn: true } : { route: rootRedirect(user, Option.fromNullishOr(model.membership)), isReturn: false } diff --git a/apps/web-foldkit/src/page/root/story.test.ts b/apps/web-foldkit/src/page/root/story.test.ts index 69fcd97af..8a0e14c8a 100644 --- a/apps/web-foldkit/src/page/root/story.test.ts +++ b/apps/web-foldkit/src/page/root/story.test.ts @@ -12,7 +12,8 @@ import { Message } from "./model" const membership = { organizationId, slug: "hazel" } const signedIn = storyUpdate(update, makeShared()) const notOnboarded = storyUpdate(update, makeShared({ currentUser: { ...currentUser, isOnboarded: false } })) -const go = (href: string) => expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(href), replace: true })) +const go = (href: string) => + expectOutMessage(PageOutMessage.RequestedNavigation({ route: routeOfPath(href), replace: true })) describe("root redirect", () => { test("reads the remembered page first", () => { diff --git a/apps/web-foldkit/src/platform/desktop-auth.ts b/apps/web-foldkit/src/platform/desktop-auth.ts index cecdb5301..0f4bf35ce 100644 --- a/apps/web-foldkit/src/platform/desktop-auth.ts +++ b/apps/web-foldkit/src/platform/desktop-auth.ts @@ -21,7 +21,10 @@ const invokeVault = <A>(command: string, args?: Record<string, unknown>) => const core = getTauriCore() return core === undefined ? Effect.fail(unavailable("storage")) - : Effect.tryPromise({ try: () => core.invoke<A>(command, args), catch: () => unavailable("storage") }) + : Effect.tryPromise({ + try: () => core.invoke<A>(command, args), + catch: () => unavailable("storage"), + }) }) /** One JSON record in the OS keychain; the Rust side never interprets it. */ @@ -68,9 +71,10 @@ export const BrowserLive = Layer.effect( )) if (deepLink !== undefined && isListening) { // Best effort: a cold-start callback is a convenience; sign-in can always be started again. - const initial = yield* Effect.tryPromise({ try: () => deepLink.getCurrent(), catch: () => unavailable() }).pipe( - Effect.orElseSucceed(() => null), - ) + const initial = yield* Effect.tryPromise({ + try: () => deepLink.getCurrent(), + catch: () => unavailable(), + }).pipe(Effect.orElseSucceed(() => null)) if (initial !== null) capture(initial) } diff --git a/apps/web-foldkit/src/platform/presence/presence.story.test.ts b/apps/web-foldkit/src/platform/presence/presence.story.test.ts index 4d309c1b2..8f34e43a7 100644 --- a/apps/web-foldkit/src/platform/presence/presence.story.test.ts +++ b/apps/web-foldkit/src/platform/presence/presence.story.test.ts @@ -175,7 +175,10 @@ describe("a failed write", () => { given(synced({ manualStatus: "busy", isSyncPending: true, syncVersion: 2 })), message(Message.ElapsedSyncDebounce({ version: 2 })), Command.expectExact(send(busy, { status: "busy" })), - Command.resolve(SendPresenceUpdate, Message.FailedSendPresenceUpdate({ userId: ada, reason: "Offline" })), + Command.resolve( + SendPresenceUpdate, + Message.FailedSendPresenceUpdate({ userId: ada, reason: "Offline" }), + ), model((m) => { expect(m.sent).toEqual(snapshot("online", general)) expect(m.failedSyncs).toBe(1) diff --git a/apps/web-foldkit/src/platform/presence/update.ts b/apps/web-foldkit/src/platform/presence/update.ts index f7cfb3d5b..14f04e31c 100644 --- a/apps/web-foldkit/src/platform/presence/update.ts +++ b/apps/web-foldkit/src/platform/presence/update.ts @@ -6,7 +6,15 @@ import { catchCommon } from "../../data/actions" import { HazelRpc } from "../../rpc" import { broadcastActivity } from "./activity" import { Message } from "./message" -import { computedStatusOf, isAfkAt, type Model, payloadOf, PresenceStatus, Sent, syncRetryDelayMs } from "./model" +import { + computedStatusOf, + isAfkAt, + type Model, + payloadOf, + PresenceStatus, + Sent, + syncRetryDelayMs, +} from "./model" export type Return = Update.Return<Model, Message, HazelRpc> diff --git a/apps/web-foldkit/src/platform/updates.ts b/apps/web-foldkit/src/platform/updates.ts index 7b08d4166..5c4479ea0 100644 --- a/apps/web-foldkit/src/platform/updates.ts +++ b/apps/web-foldkit/src/platform/updates.ts @@ -62,7 +62,8 @@ const INSTALL_FAILURE_TEXT: { readonly [Reason in InstallFailure]: string } = { NoUpdate: "No update to install", DownloadFailed: "Download failed", InstallFailed: "Installation failed", - RestartUnavailable: "Update installed, but restart is unavailable. Please quit and reopen Hazel manually.", + RestartUnavailable: + "Update installed, but restart is unavailable. Please quit and reopen Hazel manually.", RestartFailed: "Update installed, but restart failed and Hazel could not quit automatically. Please quit and reopen Hazel manually.", } @@ -134,12 +135,27 @@ const toastOf = (previous: Model, next: Model): ToastRequest | undefined => description: null, id: TOAST_ID, }), - Installing: (): ToastRequest => ({ intent: "loading", title: "Installing update...", description: null, id: TOAST_ID }), - Restarting: (): ToastRequest => ({ intent: "loading", title: "Restarting...", description: null, id: TOAST_ID }), + Installing: (): ToastRequest => ({ + intent: "loading", + title: "Installing update...", + description: null, + id: TOAST_ID, + }), + Restarting: (): ToastRequest => ({ + intent: "loading", + title: "Restarting...", + description: null, + id: TOAST_ID, + }), InstallFailed: ({ reason }): ToastRequest | undefined => previous._tag === "InstallFailed" ? undefined - : { intent: "error", title: "Update failed", description: installFailureText(reason), id: TOAST_ID }, + : { + intent: "error", + title: "Update failed", + description: installFailureText(reason), + id: TOAST_ID, + }, }) /** The next state, with the toast it calls for. */ @@ -156,7 +172,9 @@ export const check = (model: Model): Return => /** Install the update `check` found; the install stream starts once the state says Downloading. */ export const install = (model: Model): Return => - model._tag === "Available" ? transition(model, Model.Downloading({ downloadedBytes: 0, totalBytes: null })) : { model } + model._tag === "Available" + ? transition(model, Model.Downloading({ downloadedBytes: 0, totalBytes: null })) + : { model } export const update = (model: Model, message: Message): Return => Message.match<Return>(message, { @@ -182,7 +200,10 @@ const installStream: Stream.Stream<Message> = Stream.callback<Message>((queue) = const offer = (message: Message) => Effect.sync(() => Queue.offerUnsafe(queue, message)) const fail = (reason: InstallFailure) => offer(Message.FailedInstall({ reason })) const updater = getTauriUpdater() - const found = updater === undefined ? null : yield* updaterCall(() => updater.check()).pipe(Effect.orElseSucceed(() => null)) + const found = + updater === undefined + ? null + : yield* updaterCall(() => updater.check()).pipe(Effect.orElseSucceed(() => null)) if (found === null) return yield* fail("NoUpdate") let downloadedBytes = 0 diff --git a/apps/web-foldkit/src/redirect.ts b/apps/web-foldkit/src/redirect.ts index 1dd0531e4..6ffa33b08 100644 --- a/apps/web-foldkit/src/redirect.ts +++ b/apps/web-foldkit/src/redirect.ts @@ -9,7 +9,10 @@ import type { Auth, CurrentUser } from "./session" * Index routes that only forward somewhere else, whatever the session. Redirect-only routes with * a page (`ChannelSettings`) forward from the page's `init` instead. */ -export const routeRedirect = (route: AppRoute, options: { readonly isProd: boolean }): Option.Option<AppRoute> => +export const routeRedirect = ( + route: AppRoute, + options: { readonly isProd: boolean }, +): Option.Option<AppRoute> => Match.value(route).pipe( // `settings/debug.tsx` is dev-only. Match.tag("SettingsDebug", ({ orgSlug }) => diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 10b7e1146..81cf3b54d 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -156,7 +156,9 @@ describe("redirects", () => { }) it("sends signed-out visitors on app routes to sign-in", () => { - expect(Option.map(authRedirect(parse("/"), "SignedOut", "/"), hrefOf)).toEqual(Option.some("/sign-in?redirect_url=%2F")) + expect(Option.map(authRedirect(parse("/"), "SignedOut", "/"), hrefOf)).toEqual( + Option.some("/sign-in?redirect_url=%2F"), + ) expect(authRedirect(parse("/hazel/chat"), "SignedIn", "/hazel/chat")).toEqual(Option.none()) expect(authRedirect(parse("/hazel/chat"), "Loading", "/hazel/chat")).toEqual(Option.none()) expect(authRedirect(parse("/join/hazel"), "SignedOut", "/join/hazel")).toEqual(Option.none()) @@ -199,7 +201,10 @@ describe("hrefOf", () => { Root: [[AppRoute.Root(), "/"]], SignIn: [ [AppRoute.SignIn({ redirectUrl: none }), "/sign-in"], - [AppRoute.SignIn({ redirectUrl: Option.some("/hazel/chat") }), "/sign-in?redirect_url=%2Fhazel%2Fchat"], + [ + AppRoute.SignIn({ redirectUrl: Option.some("/hazel/chat") }), + "/sign-in?redirect_url=%2Fhazel%2Fchat", + ], ], SignUp: [[AppRoute.SignUp({ redirectUrl: none }), "/sign-up"]], AuthRegister: [ @@ -215,9 +220,7 @@ describe("hrefOf", () => { ], Join: [[AppRoute.Join({ slug: "hazel" }), "/join/hazel"]], Invite: [[AppRoute.Invite({ token: "abc_DEF-123" }), "/invite/abc_DEF-123"]], - AuthDesktop: [ - [AppRoute.AuthDesktop({ attempt: Option.some("a1") }), "/auth/desktop?attempt=a1"], - ], + AuthDesktop: [[AppRoute.AuthDesktop({ attempt: Option.some("a1") }), "/auth/desktop?attempt=a1"]], Onboarding: [ [AppRoute.Onboarding({ orgId: Option.none(), step: none }), "/onboarding"], [ diff --git a/apps/web-foldkit/src/rpc.ts b/apps/web-foldkit/src/rpc.ts index 8739c48b1..2b4273ea2 100644 --- a/apps/web-foldkit/src/rpc.ts +++ b/apps/web-foldkit/src/rpc.ts @@ -30,7 +30,7 @@ import { import { AuthMiddlewareClientLive, RpcCredentialsLive } from "~/lib/rpc-auth-middleware" import { CustomFetchLive } from "~/lib/services/common/api-client" import { HazelAuthClient, HazelAuthClientLive } from "./auth/client" -import { NotificationSinks } from "./app/notification-sinks" +import type { NotificationSinks } from "./app/notification-sinks" import { MessageWindows, MessageWindowsLive } from "./data/message-windows" import { EditorRegistry, EditorRegistryLive } from "./editor/registry" @@ -91,6 +91,7 @@ export type Resources = | MessageWindows | HazelAuthClient | EditorRegistry + // Provided by `entry.ts`: building the sinks touches audio, which tests importing this module lack. | NotificationSinks export const ResourcesLive = Layer.mergeAll( @@ -99,5 +100,4 @@ export const ResourcesLive = Layer.mergeAll( MessageWindowsLive, HazelAuthClientLive, EditorRegistryLive, - NotificationSinks.layer, ) diff --git a/apps/web-foldkit/src/shell/app-shell.ts b/apps/web-foldkit/src/shell/app-shell.ts index 034a5642a..37604f81c 100644 --- a/apps/web-foldkit/src/shell/app-shell.ts +++ b/apps/web-foldkit/src/shell/app-shell.ts @@ -61,52 +61,73 @@ const navRail = <Message>(h: HtmlBuilder<Message>, context: ShellContext, unread sidebarLink(h, { href: to, isActive, activeClassName: NAV_ACTIVE }, [icon]), ]) // Under the macOS window controls the dock draws its own borders, below the titlebar strip. - return sidebarStatic(h, `hidden w-[calc(var(--sidebar-width-dock)+1px)] md:flex ${hasDesktopTitlebar ? "" : "md:border-r"}`, [ - sidebarHeader(h, { state: "expanded", className: `px-3 py-4 border-b ${hasDesktopTitlebar ? "pt-14 relative before:absolute before:top-10 before:left-0 before:right-0 before:bottom-0 before:border-t before:border-r before:border-sidebar-border" : "h-14"}` }, [ - h.a( + return sidebarStatic( + h, + `hidden w-[calc(var(--sidebar-width-dock)+1px)] md:flex ${hasDesktopTitlebar ? "" : "md:border-r"}`, + [ + sidebarHeader( + h, + { + state: "expanded", + className: `px-3 py-4 border-b ${hasDesktopTitlebar ? "pt-14 relative before:absolute before:top-10 before:left-0 before:right-0 before:bottom-0 before:border-t before:border-r before:border-sidebar-border" : "h-14"}`, + }, [ - h.Class(linkClassName({ hasHref: true, className: "flex items-center justify-center" })), - h.Href(hrefOf(AppRoute.Root())), - h.Attribute("tabindex", "0"), - h.Attribute("data-react-aria-pressable", "true"), + h.a( + [ + h.Class( + linkClassName({ + hasHref: true, + className: "flex items-center justify-center", + }), + ), + h.Href(hrefOf(AppRoute.Root())), + h.Attribute("tabindex", "0"), + h.Attribute("data-react-aria-pressable", "true"), + ], + [Logo(h, { className: "size-7" })], + ), ], - [Logo(h, { className: "size-7" })], ), - ]), - sidebarContent(h, { state: "expanded", className: `mask-none ${hasDesktopTitlebar ? "border-r border-sidebar-border" : ""}` }, [ - sidebarSectionGroup(h, [ - sidebarSection(h, { className: "p-2! *:data-[slot=sidebar-section-inner]:gap-y-2" }, [ - navItem( - "Home", - home, - route._tag === "OrgHome", - IconDashboard(h, { className: "size-5" }), - ), - navItem( - "Chat", - chat, - isChatRoute(route), - IconMsgs(h, { className: "size-5" }), - ), - navItem( - "Notifications", - notifications, - isNotificationsRoute(route), - bellIcon(h, unreadCount), - ), - navItem( - "Settings", - settings, - isSettingsRoute(route), - IconGear(h, { className: "size-5" }), - ), - ]), + sidebarContent( + h, + { + state: "expanded", + className: `mask-none ${hasDesktopTitlebar ? "border-r border-sidebar-border" : ""}`, + }, + [ + sidebarSectionGroup(h, [ + sidebarSection(h, { className: "p-2! *:data-[slot=sidebar-section-inner]:gap-y-2" }, [ + navItem( + "Home", + home, + route._tag === "OrgHome", + IconDashboard(h, { className: "size-5" }), + ), + navItem("Chat", chat, isChatRoute(route), IconMsgs(h, { className: "size-5" })), + navItem( + "Notifications", + notifications, + isNotificationsRoute(route), + bellIcon(h, unreadCount), + ), + navItem( + "Settings", + settings, + isSettingsRoute(route), + IconGear(h, { className: "size-5" }), + ), + ]), + ]), + ], + ), + sidebarFooter(h, "p-2 ", [ + h.span( + [h.Class("text-center text-[10px] font-mono text-muted-fg")], + ["v", context.appVersion], + ), ]), - ]), - sidebarFooter(h, "p-2 ", [ - h.span([h.Class("text-center text-[10px] font-mono text-muted-fg")], ["v", context.appVersion]), - ]), - ]) + ], + ) } export interface MobileShell<Message> { diff --git a/apps/web-foldkit/src/shell/channels-sidebar.ts b/apps/web-foldkit/src/shell/channels-sidebar.ts index 92fec7e53..d16a98537 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar.ts @@ -144,19 +144,18 @@ export const update = (model: Model, message: Message): SidebarReturn => }) /** The root learned the organization or the signed-in user; the sidebar's queries depend on both. */ +/** The data the sidebar holds belongs to one organization and user; another pair starts it empty. */ export const setContext = ( model: Model, context: { readonly organizationId: OrganizationId | null; readonly currentUserId: UserId | null }, -): SidebarReturn => +): Model => model.organizationId === context.organizationId && model.currentUserId === context.currentUserId - ? { model } + ? model : { - model: { - ...model, - ...emptyData, - organizationId: context.organizationId, - currentUserId: context.currentUserId, - }, + ...model, + ...emptyData, + organizationId: context.organizationId, + currentUserId: context.currentUserId, } // SUBSCRIPTION diff --git a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts index bc3e41904..85c6cfad8 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts @@ -29,8 +29,15 @@ export const createChannelHint = <M>(h: HtmlBuilder<M>, onDismiss: Attribute<M>) ), ], ), - button(h, { intent: "plain", size: "sq-xs", className: "size-5", attributes: [h.AriaLabel("Dismiss"), onDismiss] }, [ - IconClose(h, { className: "size-3" }), - ]), + button( + h, + { + intent: "plain", + size: "sq-xs", + className: "size-5", + attributes: [h.AriaLabel("Dismiss"), onDismiss], + }, + [IconClose(h, { className: "size-3" })], + ), ], ) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts index 77d671586..2c06e61db 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/menus.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/menus.ts @@ -157,7 +157,8 @@ export const rowMenuView = <M>( attributes: [ h.Attribute("data-slot", "menu-trigger"), h.AriaLabel( - options.entry.channel.type === "single" || options.entry.channel.type === "direct" + options.entry.channel.type === "single" || + options.entry.channel.type === "direct" ? "Conversation options" : "Channel options", ), diff --git a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts index cdf27972d..ee5861ae2 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/tree.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/tree.ts @@ -29,7 +29,11 @@ export const dotsMenuTrigger = <M>(h: HtmlBuilder<M>, label: string): Html => intent: "plain", size: "sq-xs", className: "size-5 text-muted-fg", - attributes: [h.Attribute("data-slot", "menu-trigger"), h.AriaLabel(label), ...menuTriggerAttributes(h)], + attributes: [ + h.Attribute("data-slot", "menu-trigger"), + h.AriaLabel(label), + ...menuTriggerAttributes(h), + ], }, [IconDots(h, { className: "size-4" })], ) diff --git a/apps/web-foldkit/src/shell/menus.ts b/apps/web-foldkit/src/shell/menus.ts index 6b9d825a6..f48e3d410 100644 --- a/apps/web-foldkit/src/shell/menus.ts +++ b/apps/web-foldkit/src/shell/menus.ts @@ -32,7 +32,14 @@ export const USER_MENU_ID = "user-menu" export const ORG_SWITCHER_ID = "org-switcher" /** The user menu's items; `status`, `feedback` and `logout` act, the others are links. */ -export const UserMenuKey = Schema.Literals(["header", "profile", "status", "my-settings", "feedback", "logout"]) +export const UserMenuKey = Schema.Literals([ + "header", + "profile", + "status", + "my-settings", + "feedback", + "logout", +]) export type UserMenuKey = typeof UserMenuKey.Type /** The org switcher's fixed items; each organization's item is keyed by its id. */ @@ -53,7 +60,11 @@ export const decodeUserMenuKey = Schema.decodeUnknownOption(UserMenuKey) export const decodeSwitcherKey = Schema.decodeUnknownOption(SwitcherKey) /** Where a user menu link item goes. */ -export const userMenuLinkRoute = (key: UserMenuKey, orgSlug: string, userId: UserId): Option.Option<AppRoute> => +export const userMenuLinkRoute = ( + key: UserMenuKey, + orgSlug: string, + userId: UserId, +): Option.Option<AppRoute> => Match.value(key).pipe( Match.when("profile", () => Option.some(AppRoute.Profile({ orgSlug, userId }))), Match.when("my-settings", () => Option.some(AppRoute.MySettingsAppearance({ orgSlug }))), @@ -96,9 +107,7 @@ export const userMenuEntries = (orgSlug: string, userId: UserId): ReadonlyArray< const switchServerEntries = (organizations: ReadonlyArray<SwitcherOrg>): ReadonlyArray<Menu.Entry> => [ Menu.section( undefined, - organizations.map((organization) => - Menu.item(organization.id, { textValue: organization.name }), - ), + organizations.map((organization) => Menu.item(organization.id, { textValue: organization.name })), ), Menu.separator, Menu.item("create-server" satisfies SwitcherKey, { textValue: "Create server" }), @@ -132,9 +141,7 @@ export const orgSwitcherEntries = (options: { people, switchServer, Menu.separator, - Menu.section(undefined, [ - switcherItem("server-settings", orgSlug), - ]), + Menu.section(undefined, [switcherItem("server-settings", orgSlug)]), ] return [ people, diff --git a/apps/web-foldkit/src/shell/notifications.ts b/apps/web-foldkit/src/shell/notifications.ts index 23f26498c..f24dae6b9 100644 --- a/apps/web-foldkit/src/shell/notifications.ts +++ b/apps/web-foldkit/src/shell/notifications.ts @@ -29,7 +29,10 @@ export const Message = defineMessageUnion({ UpdatedUnreadNotifications: { ids: Schema.Array(NotificationId) }, ClickedMarkAllRead: {}, SucceededMarkNotificationRead: { id: NotificationId }, - FailedMarkNotificationRead: { id: NotificationId, reason: Schema.Union([Schema.Literal("NotFound"), CommonReason]) }, + FailedMarkNotificationRead: { + id: NotificationId, + reason: Schema.Union([Schema.Literal("NotFound"), CommonReason]), + }, }) export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/shell/shell.story.test.ts b/apps/web-foldkit/src/shell/shell.story.test.ts index 0d1f036ac..cbfd6fd2a 100644 --- a/apps/web-foldkit/src/shell/shell.story.test.ts +++ b/apps/web-foldkit/src/shell/shell.story.test.ts @@ -29,7 +29,8 @@ const userMenu = (inner: Menu.Message) => Message.GotUserMenuMessage({ message: const orgSwitcher = (inner: Menu.Message) => Message.GotOrgSwitcherMessage({ message: inner }) const opened = Menu.Message.PressedTrigger({ pointerType: "mouse" }) /** A click on `key`, with the entries the view passes the menu (the shell Model's, as `app/view.ts` does). */ -const clicked = (key: string, entries: ReadonlyArray<Menu.Entry>) => Menu.Message.ClickedItem({ key, entries }) +const clicked = (key: string, entries: ReadonlyArray<Menu.Entry>) => + Menu.Message.ClickedItem({ key, entries }) const otherOrgId = Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-0000000000a2") const unfinishedOrgId = Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-0000000000a3") @@ -85,7 +86,10 @@ describe("org switcher", () => { message(orgSwitcher(clicked("switch-server", switcherEntries))), message(orgSwitcher(clicked(otherOrgId, switcherEntries))), expectOutMessage( - PageOutMessage.RequestedNavigation({ route: AppRoute.OrgHome({ orgSlug: "acme" }), replace: false }), + PageOutMessage.RequestedNavigation({ + route: AppRoute.OrgHome({ orgSlug: "acme" }), + replace: false, + }), ), ) }) @@ -104,8 +108,10 @@ describe("org switcher", () => { describe("on mobile (a flat list of organizations)", () => { const mobileContext: Context = { ...context, isMobile: true } const runMobile = (model: Model, msg: Message) => update(model, msg, mobileContext) - const mobile = runMobile(informContext(ready, mobileContext), Message.UpdatedUserOrganizations({ organizations })) - .model + const mobile = runMobile( + informContext(ready, mobileContext), + Message.UpdatedUserOrganizations({ organizations }), + ).model test("checks the current organization", () => { expect(mobile.orgSwitcher.selectionMode).toBe("Single") @@ -120,7 +126,10 @@ describe("org switcher", () => { message(orgSwitcher(opened)), message(orgSwitcher(clicked(otherOrgId, mobile.orgSwitcherEntries))), expectOutMessage( - PageOutMessage.RequestedNavigation({ route: AppRoute.OrgHome({ orgSlug: "acme" }), replace: false }), + PageOutMessage.RequestedNavigation({ + route: AppRoute.OrgHome({ orgSlug: "acme" }), + replace: false, + }), ), ) }) diff --git a/apps/web-foldkit/src/shell/sidebars.ts b/apps/web-foldkit/src/shell/sidebars.ts index 1be89fa82..88bb3e541 100644 --- a/apps/web-foldkit/src/shell/sidebars.ts +++ b/apps/web-foldkit/src/shell/sidebars.ts @@ -112,12 +112,18 @@ export const settingsSidebar = <Message>( "Integrations", integrations, { - isCurrent: onRoutes(route, "SettingsIntegrations", "SettingsIntegration").isCurrent, + isCurrent: onRoutes(route, "SettingsIntegrations", "SettingsIntegration") + .isCurrent, isLinkActive: route._tag === "SettingsIntegrations", }, IconIntegratio, ), - item("Marketplace", marketplace, onRoutes(route, "SettingsIntegrationsMarketplace"), IconShop), + item( + "Marketplace", + marketplace, + onRoutes(route, "SettingsIntegrationsMarketplace"), + IconShop, + ), item( "Installed Apps", installed, @@ -191,7 +197,12 @@ export const mySettingsSidebar = <Message>( onRoutes(route, "MySettingsLinkedAccounts"), IconArrowPath, ), - item("Notifications", notifications, onRoutes(route, "MySettingsNotifications"), IconBell), + item( + "Notifications", + notifications, + onRoutes(route, "MySettingsNotifications"), + IconBell, + ), ...(isDesktop ? [item("Desktop", desktop, onRoutes(route, "MySettingsDesktop"), IconCube)] : []), @@ -235,7 +246,10 @@ export const notificationsSidebar = <Message>( item( "All Activity", all, - { isCurrent: isNotificationsRoute(route), isLinkActive: route._tag === "NotificationsAll" }, + { + isCurrent: isNotificationsRoute(route), + isLinkActive: route._tag === "NotificationsAll", + }, IconBell, ), item("Channels", general, onRoutes(route, "NotificationsGeneral"), IconHashtag), diff --git a/apps/web-foldkit/src/shell/update.ts b/apps/web-foldkit/src/shell/update.ts index 0d47cfc7a..280ae1787 100644 --- a/apps/web-foldkit/src/shell/update.ts +++ b/apps/web-foldkit/src/shell/update.ts @@ -66,7 +66,8 @@ const withMenuEntries = (model: Model, context: Context): Model => { // The menus only render inside the org shell, which waits for `user.me`. if (orgSlug === null) return model return modifyFields(model, { - userMenuEntries: (entries) => (currentUserId === null ? entries : userMenuEntries(orgSlug, currentUserId)), + userMenuEntries: (entries) => + currentUserId === null ? entries : userMenuEntries(orgSlug, currentUserId), orgSwitcherEntries: () => orgSwitcherEntries({ orgSlug, @@ -81,7 +82,8 @@ const withMenuEntries = (model: Model, context: Context): Model => { // Mobile shows `SwitchServerMenu` alone: a single-selection list with the current org checked. { selectionMode: () => (isMobile ? "Single" : "None"), - selectedKeys: () => (isMobile && context.organizationId !== null ? [context.organizationId] : []), + selectedKeys: () => + isMobile && context.organizationId !== null ? [context.organizationId] : [], }, ), }) @@ -91,7 +93,8 @@ const requested = (outMessage: PageOutMessage): Step => (model) => ({ model, outMessage }) -const navigateTo = (route: AppRoute): Step => requested(PageOutMessage.RequestedNavigation({ route, replace: false })) +const navigateTo = (route: AppRoute): Step => + requested(PageOutMessage.RequestedNavigation({ route, replace: false })) const unchanged: Step = (model) => ({ model }) @@ -143,7 +146,8 @@ const switchedOrganization = Option.match( Option.fromNullishOr( model.userOrganizations.find( - (organization) => organization.id === organizationId && organization.id !== context.organizationId, + (organization) => + organization.id === organizationId && organization.id !== context.organizationId, ), ), { @@ -170,7 +174,9 @@ const foldOrgSwitcherOutMessage = (context: Context) => ActivatedLink: ({ key }) => Option.match( Option.flatMap(decodeSwitcherKey(key), (switcherKey) => - context.orgSlug === null ? Option.none() : switcherLinkRoute(switcherKey, context.orgSlug), + context.orgSlug === null + ? Option.none() + : switcherLinkRoute(switcherKey, context.orgSlug), ), { onNone: () => unchanged, onSome: navigateTo }, ), @@ -236,10 +242,9 @@ export const update = (model: Model, message: Message, context: Context): ShellR /** The root learned a new route, organization, user, role or viewport. */ export const informContext = (model: Model, context: Context): Model => { - // `setContext` only drops the previous organization's data: no Commands, no OutMessage. - const sidebar = ChannelsSidebar.setContext(model.channelsSidebar, { + const channelsSidebar = ChannelsSidebar.setContext(model.channelsSidebar, { organizationId: context.organizationId, currentUserId: context.currentUserId, }) - return withMenuEntries(modifyFields(model, { channelsSidebar: () => sidebar.model }), context) + return withMenuEntries(modifyFields(model, { channelsSidebar: () => channelsSidebar }), context) } diff --git a/apps/web-foldkit/src/theme.test.ts b/apps/web-foldkit/src/theme.test.ts index 6982dcf82..164125bc0 100644 --- a/apps/web-foldkit/src/theme.test.ts +++ b/apps/web-foldkit/src/theme.test.ts @@ -17,7 +17,11 @@ import { /** The root owns the theme: the legacy storage keys, `resolvedThemeAtom`, and `RequestedTheme`. */ -const ocean = Schema.decodeSync(ThemeCustomization)({ primary: "#0EA5E9", grayPalette: "gray-cool", radius: "round" }) +const ocean = Schema.decodeSync(ThemeCustomization)({ + primary: "#0EA5E9", + grayPalette: "gray-cool", + radius: "round", +}) const load = () => Effect.runPromise(loadThemePreference) describe("stored theme preference", () => { @@ -49,7 +53,8 @@ describe("stored theme preference", () => { const boot = (themePreference = defaultThemePreference()) => Option.match(fromString("http://localhost/sign-in"), { onNone: () => expect.unreachable("url"), - onSome: (url) => init( + onSome: (url) => + init( { themePreference, systemTheme: "light", From 9ff114d7cd108487237ad10472800b0ea9c7be95 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:05:39 +0200 Subject: [PATCH 46/52] refactor(web): toasts on @foldkit/ui Toast; drop the sonner port The toaster binds the kit Toast (lifecycle, timers, hover pause, swipe) to the app's ToastRequest. The import-time sonner stylesheet injection, the hand-rolled timers and stacking, and the sonner dependency are gone. Enter/exit animate on the kit's data-closed/enter/leave attributes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/package.json | 1 - apps/web-foldkit/src/app/desktop.test.ts | 18 +- .../src/app/modal-host.scene.test.ts | 20 +- .../src/app/out-messages.story.test.ts | 9 +- apps/web-foldkit/src/app/shell.scene.test.ts | 21 +- apps/web-foldkit/src/app/subscription.ts | 2 +- apps/web-foldkit/src/app/view.ts | 4 +- .../src/core/components/ui/sonner.styles.ts | 5 - .../src/core/components/ui/toast.styles.ts | 84 +-- apps/web-foldkit/src/overlay/toaster.test.ts | 124 +++-- apps/web-foldkit/src/overlay/toaster.ts | 138 +++-- apps/web-foldkit/src/page/README.md | 4 +- apps/web-foldkit/src/test/toast-fixtures.ts | 50 ++ apps/web-foldkit/src/ui/toast-view.ts | 261 ++++----- apps/web-foldkit/src/ui/toast.scene.test.ts | 168 ++---- apps/web-foldkit/src/ui/toast.story.test.ts | 293 +++------- apps/web-foldkit/src/ui/toast.ts | 501 +++--------------- bun.lock | 3 - 18 files changed, 552 insertions(+), 1154 deletions(-) delete mode 100644 apps/web-foldkit/src/core/components/ui/sonner.styles.ts create mode 100644 apps/web-foldkit/src/test/toast-fixtures.ts diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index bb71c134e..d78343427 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -43,7 +43,6 @@ "prosemirror-view": "^1.42.6", "remend": "^1.2.2", "rivetkit": "2.1.6", - "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2" }, 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/modal-host.scene.test.ts b/apps/web-foldkit/src/app/modal-host.scene.test.ts index 4c5c2a6b8..fa85e2b4c 100644 --- a/apps/web-foldkit/src/app/modal-host.scene.test.ts +++ b/apps/web-foldkit/src/app/modal-host.scene.test.ts @@ -5,10 +5,10 @@ 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`). */ @@ -36,7 +36,7 @@ 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", () => { @@ -58,13 +58,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(), ) }) @@ -80,11 +76,7 @@ describe("modal host", () => { 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(), ) }) @@ -95,7 +87,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/out-messages.story.test.ts b/apps/web-foldkit/src/app/out-messages.story.test.ts index 22599e06d..8ab2dadac 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" @@ -17,7 +18,6 @@ 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 { Message } from "./message" @@ -134,10 +134,9 @@ describe("RequestedSignOut", () => { 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 })), + 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([])), ) }) }) diff --git a/apps/web-foldkit/src/app/shell.scene.test.ts b/apps/web-foldkit/src/app/shell.scene.test.ts index d3db9b17d..7277a0f4c 100644 --- a/apps/web-foldkit/src/app/shell.scene.test.ts +++ b/apps/web-foldkit/src/app/shell.scene.test.ts @@ -23,12 +23,12 @@ 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" @@ -73,7 +73,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 +125,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..58cb2bf94 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -224,7 +224,7 @@ const desktopMenuSubscriptions = Subscription.make<Model, Message>()(() => ({ })) const toastSubscriptions = Subscription.lift(Toasts.subscriptions)<Model, Message>({ - read: (model) => Option.some(model.toasts.toaster), + read: (model) => Option.some(model.toasts), toParentMessage: (message): Message => ({ _tag: "GotToastsMessage", message }), }) diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index 1a60521e8..3c7d351f5 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -23,7 +23,7 @@ import { mySettingsSidebar, notificationsSidebar, settingsSidebar } from "../she 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, 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,7 +43,7 @@ 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<Message>, 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", 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 `<style>` at module load; `dist/styles.css` is their unminified source. - */ -export { default as sonnerCss } from "sonner/dist/styles.css?inline" diff --git a/apps/web-foldkit/src/core/components/ui/toast.styles.ts b/apps/web-foldkit/src/core/components/ui/toast.styles.ts index 8283756eb..1517b6638 100644 --- a/apps/web-foldkit/src/core/components/ui/toast.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/toast.styles.ts @@ -1,33 +1,55 @@ -import { twJoin } from "tailwind-merge" +import { tv } from "tailwind-variants" -/** sonner `toastOptions.className`, applied to every toast `<li>`. */ -export const toastClassName = twJoin( - "not-has-data-[slot=note]:backdrop-blur-3xl *:data-icon:mt-0.5 *:data-icon:self-start has-data-description:*:data-icon:mt-1", - "**:data-action:[--normal-bg:var(--color-primary-fg)] **:data-action:[--normal-text:var(--color-primary)]", -) +/** + * The `@foldkit/ui` Toast stack. The kit pins the container bottom-right with inline styles; the + * entry `<div>` carries the kit's Animation attributes (`data-closed`, `data-enter`, `data-leave`) + * and, while swiped, `data-swipe` with an inline `translate`. + */ +export const toastEntryClassName = [ + "w-[356px] max-w-[calc(100vw-2rem)]", + "transition-[opacity,translate] duration-200 ease-out data-leave:duration-150 data-leave:ease-in", + "data-closed:translate-y-2 data-closed:opacity-0", + "data-[swipe=settling]:duration-150 data-[swipe=end]:duration-200", +].join(" ") -/** sonner `Toaster` className, on the `<ol data-sonner-toaster>`. */ -export const toasterClassName = "toaster group" - -/** sonner `Toaster` style: the theme's colors for every toast kind, in this key order. */ -export const toasterStyle = { - "--normal-bg": "var(--color-overlay)", - "--normal-text": "var(--color-overlay-fg)", - "--normal-border": "var(--color-border)", - - "--success-bg": "var(--color-success-subtle)", - "--success-border": "color-mix(in oklab, var(--success-subtle-fg) 20%, transparent)", - "--success-text": "var(--color-success-subtle-fg)", - - "--error-bg": "var(--color-danger-subtle)", - "--error-border": "color-mix(in oklab, var(--danger-subtle-fg) 20%, transparent)", - "--error-text": "var(--color-danger-subtle-fg)", - - "--warning-bg": "var(--color-warning-subtle)", - "--warning-border": "color-mix(in oklab, var(--warning-subtle-fg) 20%, transparent)", - "--warning-text": "var(--color-warning-subtle-fg)", - - "--info-bg": "var(--color-info-subtle)", - "--info-border": "color-mix(in oklab, var(--info-subtle-fg) 20%, transparent)", - "--info-text": "var(--color-info-subtle-fg)", -} as const +/** The card inside an entry: the overlay surface, tinted per kind like sonner's `richColors`. */ +export const toastStyles = tv({ + slots: { + card: [ + "relative flex w-full items-start gap-2.5 rounded-xl border p-4 pr-9 text-sm shadow-lg", + "bg-(--toast-bg) text-(--toast-fg) border-(--toast-border)", + ], + icon: "mt-0.5 size-4 shrink-0", + content: "flex min-w-0 flex-1 flex-col gap-0.5", + title: "font-medium", + description: "opacity-90", + action: [ + "shrink-0 self-center rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-fg", + "outline-hidden transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring", + ], + close: [ + "absolute top-2 right-2 rounded-md p-1 opacity-60 outline-hidden transition-opacity", + "hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring", + ], + }, + variants: { + kind: { + info: { + card: "[--toast-bg:var(--color-info-subtle)] [--toast-border:color-mix(in_oklab,var(--color-info-subtle-fg)_20%,transparent)] [--toast-fg:var(--color-info-subtle-fg)]", + }, + success: { + card: "[--toast-bg:var(--color-success-subtle)] [--toast-border:color-mix(in_oklab,var(--color-success-subtle-fg)_20%,transparent)] [--toast-fg:var(--color-success-subtle-fg)]", + }, + warning: { + card: "[--toast-bg:var(--color-warning-subtle)] [--toast-border:color-mix(in_oklab,var(--color-warning-subtle-fg)_20%,transparent)] [--toast-fg:var(--color-warning-subtle-fg)]", + }, + error: { + card: "[--toast-bg:var(--color-danger-subtle)] [--toast-border:color-mix(in_oklab,var(--color-danger-subtle-fg)_20%,transparent)] [--toast-fg:var(--color-danger-subtle-fg)]", + }, + loading: { + card: "[--toast-bg:var(--color-overlay)] [--toast-border:var(--color-border)] [--toast-fg:var(--color-overlay-fg)]", + icon: "animate-spin", + }, + }, + }, +}) diff --git a/apps/web-foldkit/src/overlay/toaster.test.ts b/apps/web-foldkit/src/overlay/toaster.test.ts index aa9d50846..d53722a03 100644 --- a/apps/web-foldkit/src/overlay/toaster.test.ts +++ b/apps/web-foldkit/src/overlay/toaster.test.ts @@ -3,37 +3,36 @@ import { Option } from "effect" import { fromString } from "foldkit/url" import { describe, expect, test } from "vitest" import { init, Message, update } from "../main" -import * as Toast from "../ui/toast" import { DEFAULT_SOUND_SETTINGS } from "../notification-sound" import { defaultThemePreference } from "../theme" +import * as Toast from "../ui/toast" import * as Toaster from "./toaster" -import { init as initToasts, push } from "./toaster" -/** `RequestedToast` → the kit's `showRequest`; a sonner `id` updates its toast in place. */ +/** `RequestedToast` → `Toaster.push`; a request `id` updates its toast in place. */ + +const available = { + intent: "info", + title: "Update", + description: null, + id: "update", + isSticky: true, + action: { label: "Install & Restart", key: "InstallUpdate" }, +} as const describe("root toaster", () => { - test("a request adds a toast with the kit's kind, title and description", () => { - const shown = push(initToasts(), { intent: "error", title: "Failed", description: "Try again" }) - expect(shown.model.toaster.toasts).toHaveLength(1) - expect(shown.model.toaster.toasts[0]).toMatchObject({ - kind: "error", + test("a request adds a toast with its kind, title and description", () => { + const shown = Toaster.push(Toaster.init(), { + intent: "error", title: "Failed", description: "Try again", }) - }) - - test("a toast with a known id replaces it (loading, then success)", () => { - const loading = push(initToasts(), { - intent: "loading", - title: "Saving...", - description: null, - id: "save", + expect(shown.model.entries).toHaveLength(1) + expect(shown.model.entries[0]?.variant).toBe("Error") + expect(shown.model.entries[0]?.payload).toMatchObject({ + kind: "error", + title: "Failed", + maybeDescription: Option.some("Try again"), }) - const done = push(loading.model, { intent: "success", title: "Saved", description: null, id: "save" }) - expect(done.model.toaster.toasts).toHaveLength(1) - expect(done.model.toaster.toasts[0]).toMatchObject({ kind: "success", title: "Saved" }) - const other = push(done.model, { intent: "info", title: "Other", description: null }) - expect(other.model.toaster.toasts.map((toast) => toast.title)).toEqual(["Other", "Saved"]) }) test("a page's RequestedToast goes through the root into the toaster", () => { @@ -52,55 +51,84 @@ describe("root toaster", () => { url, ), }) - expect(booted.model.toasts.toaster.toasts).toMatchObject([ - { kind: "error", title: "Failed to link Discord account", description: "db_error" }, + expect(booted.model.toasts.entries.map((entry) => entry.payload)).toMatchObject([ + { + kind: "error", + title: "Failed to link Discord account", + maybeDescription: Option.some("db_error"), + }, ]) // The kit's own Messages fold back into the slot through `GotToastsMessage`. + const entryId = booted.model.toasts.entries[0]!.id const hovered = update( booted.model, - Message.GotToastsMessage({ message: Toast.Message.HoveredToaster() }), + Message.GotToastsMessage({ + message: Toaster.Message.GotToastMessage({ + message: Toast.Message.HoveredEntry({ entryId }), + }), + }), ) - expect(hovered.model.toasts.toaster.isExpanded).toBe(true) + expect(hovered.model.toasts.entries[0]?.isHovered).toBe(true) }) -}) -const available = { - intent: "info", - title: "Update", - description: null, - id: "update", - isSticky: true, - action: { label: "Install & Restart", key: "InstallUpdate" }, -} as const + test("an action click dismisses the toast and reports the action", () => { + const shown = Toaster.push(Toaster.init(), available).model + const entryId = shown.entries[0]!.id + const clicked = Toaster.update( + shown, + Toaster.Message.ClickedAction({ entryId, action: "InstallUpdate" }), + ) + expect(clicked.action).toBe("InstallUpdate") + expect(clicked.model.entries[0]?.animation.isShowing).toBe(false) + }) + + test("the kit's own Messages report no action", () => { + const shown = Toaster.push(Toaster.init(), available).model + const entryId = shown.entries[0]!.id + const dismissed = Toaster.update( + shown, + Toaster.Message.GotToastMessage({ message: Toast.Message.Dismissed({ entryId }) }), + ) + expect(dismissed.action).toBeUndefined() + expect(dismissed.model.entries[0]?.animation.isShowing).toBe(false) + }) +}) describe("update toast replacement", () => { - test("clears sticky lifetime and action when a failure replaces availability", () => { + test("a failure replaces availability in place, dropping the action and starting a timer", () => { const initial = Toaster.push(Toaster.init(), available).model const failed = Toaster.push(initial, { intent: "error", title: "Failed", description: null, id: "update", - }).model - expect(failed.toaster.toasts[0]).toMatchObject({ - actionLabel: null, - duration: 4000, - isRemoved: false, }) - expect(failed.actionsById).toEqual({}) + expect(failed.model.entries).toHaveLength(1) + expect(failed.model.entries[0]).toMatchObject({ + id: initial.entries[0]!.id, + variant: "Error", + pendingDismissVersion: 1, + payload: { kind: "error", title: "Failed", maybeAction: Option.none() }, + }) + expect(Option.isSome(failed.model.entries[0]!.maybeDuration)).toBe(true) + expect(failed.commands?.map((command) => command.name)).toEqual(["WaitBeforeDismissal"]) }) - test("creates a visible toast after its action dismissed the previous one", () => { + + test("creates a new toast after its action dismissed the previous one", () => { const initial = Toaster.push(Toaster.init(), available).model - const id = initial.idsByKey.update! - const clicked = Toaster.update(initial, Toaster.Message.ClickedAction({ id })).model - const downloading = Toaster.push(clicked, { + const entryId = initial.entries[0]!.id + const clicked = Toaster.update( + initial, + Toaster.Message.ClickedAction({ entryId, action: "InstallUpdate" }), + ) + const downloading = Toaster.push(clicked.model, { intent: "loading", title: "Downloading", description: null, id: "update", }).model - const nextId = downloading.idsByKey.update! - expect(nextId).not.toBe(id) - expect(downloading.toaster.toasts.find((toast) => toast.id === nextId)?.isRemoved).toBe(false) + expect(downloading.entries).toHaveLength(2) + expect(downloading.entries[1]?.id).not.toBe(entryId) + expect(downloading.entries[1]?.animation.isShowing).toBe(true) }) }) diff --git a/apps/web-foldkit/src/overlay/toaster.ts b/apps/web-foldkit/src/overlay/toaster.ts index 767c59068..0f68e3fef 100644 --- a/apps/web-foldkit/src/overlay/toaster.ts +++ b/apps/web-foldkit/src/overlay/toaster.ts @@ -1,107 +1,93 @@ -import { Schema } from "effect" -import type { Update } from "foldkit" +import { Option, Schema } from "effect" +import { Command, Subscription, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { modifyFields } from "foldkit/struct" +import { defineMessageUnion } from "foldkit/message" +import { toastEntryClassName } from "~/components/ui/toast.styles" import * as Toast from "../ui/toast" import * as ToastView from "../ui/toast-view" +import { ToastAction, ToastRequest } from "./toasts" /** - * The root's toaster (legacy sonner `<Toaster>`): every page, modal and palette toast lands here - * through `push`. Sonner ids are strings; the kit's are numbers, so the slot keeps the mapping. + * The root's toaster: every page, modal and palette toast lands here through `push`. The stack is + * the `@foldkit/ui` Toast, pinned bottom-right; an action button reports its `ToastAction` from + * `update` for the root to carry out. */ -import { ToastAction, ToastRequest } from "./toasts" - export { ToastRequest } -export const Model = Schema.Struct({ - toaster: Toast.Model, - /** Sonner id → the kit's numeric id of the toast it last showed. */ - idsByKey: Schema.Record(Schema.String, Schema.Number), - /** The kit's numeric id → the action its button reports. */ - actionsById: Schema.Record(Schema.String, ToastAction), -}) -export type Model = typeof Model.Type +export const Model = Toast.Toast.Model +export type Model = Toast.Model -export const Message = Toast.Message -export type Message = Toast.Message +export const Message = defineMessageUnion({ + GotToastMessage: { message: Toast.Message }, + ClickedAction: { entryId: Schema.String, action: ToastAction }, +}) +export type Message = typeof Message.Type -export const init = (): Model => ({ toaster: Toast.init(), idsByKey: {}, actionsById: {} }) +export const init = (): Model => Toast.init() type Return = Update.Return<Model, Message> -type ReturnWithAction = Update.Return<Model, Message> & { readonly action?: ToastAction } +type ReturnWithAction = Return & { readonly action?: ToastAction } + +/** The kit's `DismissedToast` OutMessage is dropped: nothing waits for a toast to leave. */ +const lift = (result: { + readonly model: Model + readonly commands?: Update.Return<Model, Toast.Message>["commands"] +}): Return => ({ + model: result.model, + commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotToastMessage({ message })), +}) // UPDATE /** `toast.success(title, { id, description })` and friends; a known `id` updates that toast in place. */ -export const push = (model: Model, request: ToastRequest): Return => { - const previousId = request.id === undefined ? undefined : model.idsByKey[request.id] - const known = model.toaster.toasts.some((toast) => toast.id === previousId && !toast.isRemoved) - ? previousId - : undefined - const { kind, title, description } = Toast.fromIntentRequest(request) - const extras = { - actionLabel: request.action?.label ?? null, - duration: request.isSticky ? Number.POSITIVE_INFINITY : 4000, - } - const shown = - known === undefined - ? Toast.show(model.toaster, { - kind, - title, - ...(description === undefined ? {} : { description }), - ...extras, - }) - : Toast.show(model.toaster, { - id: known, - kind, - title, - ...(description === undefined ? {} : { description }), - ...extras, - }) - return { - model: modifyFields(model, { - toaster: () => shown.model, - idsByKey: (ids) => (request.id === undefined ? ids : { ...ids, [request.id]: shown.id }), - actionsById: (actions) => - request.action === undefined - ? Object.fromEntries(Object.entries(actions).filter(([id]) => id !== String(shown.id))) - : { ...actions, [String(shown.id)]: request.action.key }, - }), - commands: shown.commands ?? [], - } -} +export const push = (model: Model, request: ToastRequest): Return => + lift( + Toast.show( + model, + { + kind: request.intent, + title: request.title, + maybeDescription: Option.fromNullishOr(request.description), + maybeAction: Option.fromUndefinedOr(request.action), + maybeKey: Option.fromUndefinedOr(request.id), + }, + request.isSticky === true, + ), + ) -/** The kit's `ClickedAction` becomes the toast's `action`, for the root to carry out. */ -export const update = (model: Model, message: Message): ReturnWithAction => { - const result = Toast.update(model.toaster, message) - const next = { - model: modifyFields(model, { toaster: () => result.model }), - commands: result.commands ?? [], - } - const clicked = result.outMessage - if (clicked === undefined || clicked._tag !== "ClickedAction") return next - const action = model.actionsById[String(clicked.id)] - return action === undefined ? next : { ...next, action } -} +/** Clicking a toast's action dismisses it and hands its `action` to the root. */ +export const update = (model: Model, message: Message): ReturnWithAction => + Message.match<ReturnWithAction>(message, { + GotToastMessage: ({ message: toastMessage }) => lift(Toast.update(model, toastMessage)), + ClickedAction: ({ entryId, action }) => ({ ...lift(Toast.dismiss(model, entryId)), action }), + }) // SUBSCRIPTION -export const subscriptions = Toast.subscriptions +export const subscriptions = Subscription.lift(Toast.subscriptions)<Model, Message>({ + read: (model) => Option.some(model), + toParentMessage: (message) => Message.GotToastMessage({ message }), +}) // VIEW -/** Sonner's toaster section, the first child of `#app` where legacy renders `<Toaster>`. */ export const view = <ParentMessage>( h: HtmlBuilder<ParentMessage>, model: Model, - theme: ToastView.ViewInputs["theme"], toParentMessage: (message: Message) => ParentMessage, ): Html => h.submodel({ - slotId: "toaster", - model: model.toaster, - view: ToastView.view, - viewInputs: { theme }, - toParentMessage, + slotId: model.id, + model, + view: Toast.Toast.view, + viewInputs: { + position: "BottomRight", + entryClassName: toastEntryClassName, + entryToView: (entry, handlers) => + ToastView.entryView(h, entry, handlers, (entryId, action) => + toParentMessage(Message.ClickedAction({ entryId, action: action.key })), + ), + }, + toParentMessage: (message) => toParentMessage(Message.GotToastMessage({ message })), }) diff --git a/apps/web-foldkit/src/page/README.md b/apps/web-foldkit/src/page/README.md index 46b878eaf..507c481a7 100644 --- a/apps/web-foldkit/src/page/README.md +++ b/apps/web-foldkit/src/page/README.md @@ -60,9 +60,9 @@ memoized views and a `sharedChanged` hook. `page/root/` shows a page that only r the theme classes or storage in a page), `RequestedSoundSettings({ settings })`, `RequestedCurrentUserRefresh({ toast? })` (re-runs `user.me`, e.g. after an avatar change) (`page/out-message.ts`). Plain links need nothing: an `<a href>` is followed by the app. - - A toast with an `id` replaces the toast with the same id (sonner's `id`): give a loading toast an + - A toast with an `id` replaces the live toast with the same id in place: give a loading toast an id and send the success or error toast with the same id. The root's toaster (`overlay/toaster.ts`, - the kit's sonner port) renders it; pages only import `ToastRequest` from `overlay/toasts.ts`. + on the `@foldkit/ui` Toast) renders it; pages only import `ToastRequest` from `overlay/toasts.ts`. - One `update` returns one OutMessage. Navigation and a toast together: `RequestedNavigation`'s `toast`. A modal's own result (toast, navigation, closing) is the modal's job, not the page's: the page only sends `RequestedModal` with the request fields the modal declares. diff --git a/apps/web-foldkit/src/test/toast-fixtures.ts b/apps/web-foldkit/src/test/toast-fixtures.ts new file mode 100644 index 000000000..6e11ead0c --- /dev/null +++ b/apps/web-foldkit/src/test/toast-fixtures.ts @@ -0,0 +1,50 @@ +import * as Animation from "@foldkit/ui/animation" +import * as UiToast from "@foldkit/ui/toast" +import * as Scene from "foldkit/scene" + +/** + * Scene steps for the root toaster's `@foldkit/ui` Toast. `Toast.test.drainEntry` is Story-only, so + * these are its Scene twins. The enter transition is generation 1, the leave transition generation 2. + */ + +const ENTER = 1 +const LEAVE = 2 + +/** The first toast the root shows. */ +export const FIRST_TOAST = "toaster-entry-0" + +/** Runs a shown toast through its enter animation; its auto-dismiss timer stays pending. */ +const enterToast = (entryId: string = FIRST_TOAST) => + Scene.Command.resolveAll( + [ + Animation.WaitForPaint({ generation: ENTER }), + Animation.Message.CompletedWaitForPaint({ generation: ENTER }), + ], + [ + Animation.WaitForAnimationSettled({ id: entryId, generation: ENTER }), + Animation.Message.EndedAnimation({ generation: ENTER }), + ], + ) + +/** Runs a dismissed toast through its exit animation, which removes it. */ +export const leaveToast = (entryId: string = FIRST_TOAST) => + Scene.Command.resolveAll( + [ + Animation.WaitForPaint({ generation: LEAVE }), + Animation.Message.CompletedWaitForPaint({ generation: LEAVE }), + ], + [ + Animation.WaitForAnimationSettled({ id: entryId, generation: LEAVE }), + Animation.Message.EndedAnimation({ generation: LEAVE }), + ], + ) + +/** A shown toast's whole life: enter, its 4s lifetime running out, then exit. */ +export const drainToast = (entryId: string = FIRST_TOAST, version = 0) => [ + enterToast(entryId), + Scene.Command.resolve( + UiToast.WaitBeforeDismissal, + UiToast.Message.CompletedWaitBeforeDismissal({ entryId, version }), + ), + leaveToast(entryId), +] diff --git a/apps/web-foldkit/src/ui/toast-view.ts b/apps/web-foldkit/src/ui/toast-view.ts index d254e4bcc..2bd788926 100644 --- a/apps/web-foldkit/src/ui/toast-view.ts +++ b/apps/web-foldkit/src/ui/toast-view.ts @@ -1,23 +1,15 @@ -import { Array, Option } from "effect" -import { Submodel } from "foldkit" +import type { EntryHandlers } from "@foldkit/ui/toast" +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" -import { toastClassName, toasterClassName, toasterStyle } from "~/components/ui/toast.styles" -import { - GAP, - type Item, - type Kind, - MeasureToast, - Message, - type Model, - offsetOf, - VISIBLE_TOASTS, -} from "./toast" +import { toastStyles } from "~/components/ui/toast.styles" +import { IconClose } from "../icons" +import type { Action, Entry, Kind } from "./toast" // Pending-button announcements; every root renders the toaster, so they load with it. import "./aria/pending-announcement" -/** sonner's Toaster markup (`position="bottom-right"`, `richColors`, the shared className and style). */ +/** One toast's card, drawn inside the kit's entry `<div>`: icon, title, description, action, close. */ -// sonner's built-in icons (dist/index.mjs), as React renders them. +// sonner's built-in icons, which the app's toasts have always shown. const iconPaths: Readonly<Record<Exclude<Kind, "loading">, { viewBox: string; d: string }>> = { success: { viewBox: "0 0 20 20", @@ -37,169 +29,96 @@ const iconPaths: Readonly<Record<Exclude<Kind, "loading">, { viewBox: string; d: }, } -const icon = (h: HtmlBuilder<Message>, kind: Exclude<Kind, "loading">): Html => - h.svg( - [ - h.Attribute("xmlns", "http://www.w3.org/2000/svg"), - h.Attribute("viewBox", iconPaths[kind].viewBox), - h.Attribute("fill", "currentColor"), - h.Attribute("height", "20"), - h.Attribute("width", "20"), - ], - [ - h.path([ - h.Attribute("fill-rule", "evenodd"), - h.Attribute("d", iconPaths[kind].d), - h.Attribute("clip-rule", "evenodd"), - ]), - ], - ) +const kindIcon = <M>(h: HtmlBuilder<M>, kind: Kind, className: string): Html => + kind === "loading" + ? h.svg( + [ + h.Attribute("viewBox", "0 0 24 24"), + h.Attribute("fill", "none"), + h.Attribute("aria-hidden", "true"), + h.Attribute("class", className), + h.DataAttribute("slot", "icon"), + ], + [ + h.circle([ + h.Attribute("cx", "12"), + h.Attribute("cy", "12"), + h.Attribute("r", "9"), + h.Attribute("stroke", "currentColor"), + h.Attribute("stroke-opacity", "0.25"), + h.Attribute("stroke-width", "3"), + ]), + h.path([ + h.Attribute("d", "M21 12a9 9 0 0 0-9-9"), + h.Attribute("stroke", "currentColor"), + h.Attribute("stroke-width", "3"), + h.Attribute("stroke-linecap", "round"), + ]), + ], + ) + : h.svg( + [ + h.Attribute("viewBox", iconPaths[kind].viewBox), + h.Attribute("fill", "currentColor"), + h.Attribute("aria-hidden", "true"), + h.Attribute("class", className), + h.DataAttribute("slot", "icon"), + ], + [ + h.path([ + h.Attribute("fill-rule", "evenodd"), + h.Attribute("d", iconPaths[kind].d), + h.Attribute("clip-rule", "evenodd"), + ]), + ], + ) -const loader = (h: HtmlBuilder<Message>, isVisible: boolean): Html => - h.div( - [h.Class("sonner-loading-wrapper"), h.DataAttribute("visible", String(isVisible))], +/** `onAction` is the Message the action button dispatches; the close button dismisses through the kit. */ +export const entryView = <M>( + h: HtmlBuilder<M>, + entry: Entry, + handlers: EntryHandlers, + onAction: (entryId: string, action: Action) => M, +): Html => { + const { kind, title, maybeDescription, maybeAction } = entry.payload + const styles = toastStyles({ kind }) + return h.div( + [h.Class(styles.card()), h.DataAttribute("kind", kind)], [ + kindIcon(h, kind, styles.icon()), h.div( - [h.Class("sonner-spinner")], - Array.makeBy(12, () => h.div([h.Class("sonner-loading-bar")])), - ), - ], - ) - -/** React writes `className=""` as an empty attribute. */ -const emptyClass = (h: HtmlBuilder<Message>) => h.Attribute("class", "") - -const iconSlot = (h: HtmlBuilder<Message>, item: Item): ReadonlyArray<Html> => { - if (item.kind === null && !item.isPromise) return [] - const children = [ - ...(item.isPromise || item.kind === "loading" ? [loader(h, item.kind === "loading")] : []), - ...(item.kind !== null && item.kind !== "loading" ? [icon(h, item.kind)] : []), - ] - return [h.div([emptyClass(h), h.DataAttribute("icon", "")], children)] -} - -const styleText = (properties: ReadonlyArray<readonly [string, string | number]>) => - properties.map(([name, value]) => `${name}: ${value};`).join(" ") - -const toastView = (h: HtmlBuilder<Message>, model: Model, item: Item, index: number): Html => { - const flag = (name: string, value: boolean) => h.DataAttribute(name, String(value)) - return h.li( - [ - h.Key(String(item.id)), - h.Tabindex(0), - h.Class(toastClassName), - h.DataAttribute("sonner-toast", ""), - flag("rich-colors", true), - flag("styled", true), - flag("mounted", item.isMounted), - flag("promise", item.isPromise), - flag("swiped", false), - flag("removed", item.isRemoved), - flag("visible", index + 1 <= VISIBLE_TOASTS), - h.DataAttribute("y-position", "bottom"), - h.DataAttribute("x-position", "right"), - h.DataAttribute("index", String(index)), - flag("front", index === 0), - flag("swiping", false), - flag("dismissible", true), - ...(item.kind === null ? [] : [h.DataAttribute("type", item.kind)]), - flag("swipe-out", false), - flag("expanded", model.isExpanded), - h.Attribute( - "style", - styleText([ - ["--index", index], - ["--toasts-before", index], - ["--z-index", model.toasts.length - index], - ["--offset", `${offsetOf(model, item)}px`], - ["--initial-height", `${item.initialHeight}px`], - ]), + [h.Class(styles.content())], + [ + h.div([h.Class(styles.title()), h.DataAttribute("slot", "title")], [title]), + ...Option.match(maybeDescription, { + onNone: () => [], + onSome: (description) => [ + h.div( + [h.Class(styles.description()), h.DataAttribute("slot", "description")], + [description], + ), + ], + }), + ], ), - h.OnMount(MeasureToast({ id: item.id })), - ], - [ - ...iconSlot(h, item), - h.div( - [emptyClass(h), h.DataAttribute("content", "")], + ...Option.match(maybeAction, { + onNone: () => [], + onSome: (action) => [ + h.button( + [h.Type("button"), h.Class(styles.action()), h.OnClick(onAction(entry.id, action))], + [action.label], + ), + ], + }), + h.button( [ - h.div([emptyClass(h), h.DataAttribute("title", "")], [item.title]), - ...(item.description === null - ? [] - : [h.div([emptyClass(h), h.DataAttribute("description", "")], [item.description])]), + ...handlers.dismiss, + h.Type("button"), + h.AriaLabel("Dismiss notification"), + h.Class(styles.close()), ], + [IconClose(h, { className: "size-3.5" })], ), - ...(item.actionLabel === null - ? [] - : [ - h.button( - [ - emptyClass(h), - h.DataAttribute("button", "true"), - h.DataAttribute("action", "true"), - h.OnClick(Message.ClickedAction({ id: item.id })), - ], - [item.actionLabel], - ), - ]), ], ) } - -export type ViewInputs = Readonly<{ theme: "light" | "dark" }> - -const regionAttributes = <M>(h: HtmlBuilder<M>) => [ - h.Attribute("aria-label", "Notifications alt+T"), - h.Tabindex(-1), - h.Attribute("aria-live", "polite"), - h.Attribute("aria-relevant", "additions text"), - h.Attribute("aria-atomic", "false"), -] - -/** The `<section>` sonner always renders, with the toast list while any toast is queued. */ -export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => - h.section( - regionAttributes(h), - model.toasts.length === 0 - ? [] - : [ - h.ol( - [ - h.Attribute("dir", "ltr"), - h.Tabindex(-1), - h.Class(toasterClassName), - h.DataAttribute("sonner-toaster", "true"), - h.DataAttribute("sonner-theme", viewInputs.theme), - h.DataAttribute("y-position", "bottom"), - h.DataAttribute("x-position", "right"), - h.Attribute( - "style", - styleText([ - [ - "--front-toast-height", - `${Option.getOrElse( - Option.map(Array.head(model.heights), (height) => height.height), - () => 0, - )}px`, - ], - ["--width", "356px"], - ["--gap", `${GAP}px`], - ...Object.entries(toasterStyle), - ...(["top", "right", "bottom", "left"] as const).map( - (side) => [`--offset-${side}`, "24px"] as const, - ), - ...(["top", "right", "bottom", "left"] as const).map( - (side) => [`--mobile-offset-${side}`, "16px"] as const, - ), - ]), - ), - h.OnMouseEnter(Message.HoveredToaster()), - ...(model.isExpanded ? [] : [h.OnMouseMove(Message.HoveredToaster())]), - h.OnMouseLeave(Message.LeftToaster()), - h.OnPointerDown(() => Option.some(Message.PressedToaster())), - h.OnPointerUp(() => Option.some(Message.ReleasedToaster())), - ], - Array.map(model.toasts, (item, index) => toastView(h, model, item, index)), - ), - ], - ), -) diff --git a/apps/web-foldkit/src/ui/toast.scene.test.ts b/apps/web-foldkit/src/ui/toast.scene.test.ts index 074211ca8..5d35c8584 100644 --- a/apps/web-foldkit/src/ui/toast.scene.test.ts +++ b/apps/web-foldkit/src/ui/toast.scene.test.ts @@ -1,162 +1,88 @@ // @vitest-environment jsdom import * as Scene from "foldkit/scene" +import type { HtmlBuilder } from "foldkit/html" import { describe, test } from "vitest" -import { - FocusToaster, - init, - MeasureToast, - Message, - type Model, - OutMessage, - PauseToastTimers, - type ShowOptions, - show, - update, - WaitForToastRemoval, -} from "./toast" -import { view } from "./toast-view" +import * as Toaster from "../overlay/toaster" +import { leaveToast } from "../test/toast-fixtures" -/** The sonner Toaster through the view: the live region, the stack, expansion and the action button. */ +/** The root toaster through its view: the live region, a toast's card, its action and dismissal. */ -const config = { update, view: Scene.withViewInputs(view, { theme: "light" })() } -const persistent = { duration: Number.POSITIVE_INFINITY } -const queued = (...options: ReadonlyArray<ShowOptions>): Model => - options.reduce((model, next) => show(model, { ...persistent, ...next }).model, init()) -const region = Scene.role("region", { name: "Notifications alt+T" }) -const toaster = Scene.selector("[data-sonner-toaster]") -const measured = (id: number, height: number) => - Scene.Mount.resolve(MeasureToast({ id }), Message.MeasuredToast({ id, height })) -const deleted = queued({ title: "Message deleted", actionLabel: "Undo" }) +type Model = Toaster.Model +type Message = Toaster.Message -describe("toast scene", () => { - test("an empty toaster is a polite live region with no list", () => { +const config = { + // The root carries out a clicked action; here it surfaces as the OutMessage. + update: (model: Model, message: Message) => { + const result = Toaster.update(model, message) + return { model: result.model, commands: result.commands ?? [], outMessage: result.action } + }, + view: (model: Model, h: HtmlBuilder<Message>) => Toaster.view(h, model, (message) => message), +} + +const region = Scene.role("region", { name: "Notifications" }) +const withToast = (request: Toaster.ToastRequest): Model => Toaster.push(Toaster.init(), request).model + +describe("toaster scene", () => { + test("an empty toaster is a polite live region", () => { Scene.scene( config, - Scene.given(init()), + Scene.given(Toaster.init()), Scene.expect(region).toHaveAttr("aria-live", "polite"), Scene.expect(region).toBeEmpty(), - Scene.Mount.expectNone(), ) }) - test("a toast shows its title, description and kind once measured", () => { + test("a toast shows its title, description and kind, entering from closed", () => { Scene.scene( config, Scene.given( - queued({ - kind: "success", + withToast({ + intent: "success", title: "Invitation sent", description: "ada@hazel.sh will get an email.", }), ), - Scene.Mount.expectExact(MeasureToast({ id: 1 })), - measured(1, 52), - Scene.expect(toaster).toHaveAttr("data-sonner-theme", "light"), + Scene.expect(Scene.role("status")).toHaveAttr("data-closed", ""), Scene.expect(Scene.text("Invitation sent")).toExist(), Scene.expect(Scene.text("ada@hazel.sh will get an email.")).toExist(), - Scene.expect(Scene.selector("[data-sonner-toast]")).toHaveAttr("data-type", "success"), - Scene.expect(Scene.selector("[data-sonner-toast]")).toHaveAttr("data-mounted", "true"), - ) - }) - - test("the newest toast sits in front and only three toasts are visible", () => { - Scene.scene( - config, - Scene.given( - queued({ title: "First" }, { title: "Second" }, { title: "Third" }, { title: "Fourth" }), - ), - Scene.Mount.resolveAll( - [MeasureToast({ id: 4 }), Message.MeasuredToast({ id: 4, height: 40 })], - [MeasureToast({ id: 3 }), Message.MeasuredToast({ id: 3, height: 40 })], - [MeasureToast({ id: 2 }), Message.MeasuredToast({ id: 2, height: 40 })], - [MeasureToast({ id: 1 }), Message.MeasuredToast({ id: 1, height: 40 })], - ), - Scene.expectAll(Scene.all.selector("[data-sonner-toast]")).toHaveCount(4), - Scene.expect(Scene.first(Scene.all.selector("[data-sonner-toast]"))).toHaveText("Fourth"), - Scene.expect(Scene.first(Scene.all.selector("[data-sonner-toast]"))).toHaveAttr( - "data-front", - "true", - ), - Scene.expect(Scene.nth(Scene.all.selector("[data-sonner-toast]"), 2)).toHaveAttr( - "data-visible", - "true", - ), - Scene.expect(Scene.last(Scene.all.selector("[data-sonner-toast]"))).toHaveAttr( - "data-visible", - "false", - ), - ) - }) - - test("hovering the toaster expands the stack and pauses the timers", () => { - Scene.scene( - config, - Scene.given(queued({ title: "Saved" }, { title: "Synced" })), - measured(2, 40), - measured(1, 40), - Scene.expect(Scene.first(Scene.all.selector("[data-sonner-toast]"))).toHaveAttr( - "data-expanded", - "false", - ), - Scene.hover(toaster), - Scene.Command.expectExact(PauseToastTimers()), - Scene.Command.resolve(PauseToastTimers, Message.PausedTimers({ at: 0 })), - Scene.expect(Scene.first(Scene.all.selector("[data-sonner-toast]"))).toHaveAttr( - "data-expanded", - "true", - ), - ) - }) - - test("the action button removes its toast after the exit animation and tells the parent", () => { - Scene.scene( - config, - Scene.given(deleted), - measured(1, 48), - Scene.click(Scene.role("button", { name: "Undo" })), - Scene.expectOutMessage(OutMessage.ClickedAction({ id: 1 })), - Scene.expect(Scene.selector("[data-sonner-toast]")).toHaveAttr("data-removed", "true"), - Scene.Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), - Scene.Mount.expectEnded(MeasureToast), - Scene.expect(region).toBeEmpty(), + Scene.expect(Scene.selector("[data-kind]")).toHaveAttr("data-kind", "success"), ) }) - test("pressing inside the toaster pauses the timers until the press ends", () => { + test("an error toast is an alert", () => { Scene.scene( config, - Scene.given(deleted), - measured(1, 48), - Scene.pointerDown(toaster), - Scene.Command.resolve(PauseToastTimers, Message.PausedTimers({ at: 0 })), - Scene.pointerUp(toaster), - Scene.Command.expectNone(), - Scene.expect(Scene.text("Message deleted")).toExist(), + Scene.given(withToast({ intent: "error", title: "Failed", description: null })), + Scene.expect(Scene.role("alert")).toHaveText(/Failed/), ) }) - test("alt+T expands the stack and moves focus into the toaster", () => { + test("the action button reports its action and dismisses the toast", () => { Scene.scene( config, - Scene.given(deleted), - measured(1, 48), - Scene.Subscription.emit(Message.PressedHotkey()), - Scene.Command.expectExact(PauseToastTimers(), FocusToaster()), - Scene.Command.resolveAll( - [PauseToastTimers, Message.PausedTimers({ at: 0 })], - [FocusToaster, Message.CompletedFocusToaster()], + Scene.given( + withToast({ + intent: "info", + title: "Update available", + description: null, + isSticky: true, + action: { label: "Install & Restart", key: "InstallUpdate" }, + }), ), - Scene.expect(Scene.selector("[data-sonner-toast]")).toHaveAttr("data-expanded", "true"), + Scene.click(Scene.role("button", { name: "Install & Restart" })), + Scene.expectOutMessage("InstallUpdate"), + leaveToast(), + Scene.expect(Scene.text("Update available")).toBeAbsent(), ) }) - test("a loading toast shows the spinner instead of an icon", () => { + test("the close button dismisses a sticky toast", () => { Scene.scene( config, - Scene.given(queued({ kind: "loading", title: "Uploading attachment" })), - measured(1, 48), - Scene.expect(Scene.selector(".sonner-loading-wrapper")).toHaveAttr("data-visible", "true"), - Scene.expect(Scene.selector("[data-sonner-toast]")).toHaveAttr("data-type", "loading"), + Scene.given(withToast({ intent: "loading", title: "Installing update...", description: null })), + Scene.click(Scene.role("button", { name: "Dismiss notification" })), + leaveToast(), + Scene.expect(Scene.text("Installing update...")).toBeAbsent(), ) }) }) diff --git a/apps/web-foldkit/src/ui/toast.story.test.ts b/apps/web-foldkit/src/ui/toast.story.test.ts index 4f22fd3ce..c7bfa32cd 100644 --- a/apps/web-foldkit/src/ui/toast.story.test.ts +++ b/apps/web-foldkit/src/ui/toast.story.test.ts @@ -1,247 +1,82 @@ -// @vitest-environment jsdom -import { Command, expectNoOutMessage, expectOutMessage, given, message, model, story } from "foldkit/story" +import * as UiToast from "@foldkit/ui/toast" +import { Option } from "effect" +import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { - dismiss, - FocusToaster, - init, - Message, - type Model, - offsetOf, - OutMessage, - PauseToastTimers, - show, - StartToastTimer, - subscriptions, - update, - WaitForToastLifetime, - WaitForToastRemoval, -} from "./toast" +import { init, Message, type Payload, show, update } from "./toast" -/** sonner's Toaster: newest-first stack, 4s lifetimes paused by hover, press and hidden tabs. */ +/** The app's payload on the `@foldkit/ui` Toast: kinds, stickiness and in-place replacement by key. */ -const commandsOf = (result: { - readonly commands?: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> -}) => (result.commands ?? []).map(({ name, args }) => ({ name, args })) -const one = show(init(), { kind: "success", title: "Channel created" }).model -// One toast whose timer started at t=1000. -const running: Model = update(one, Message.StartedTimer({ id: 1, version: 1, at: 1000 })).model -const two = show(running, { title: "Message copied" }).model -const toastAt = (model: Model, index: number) => model.toasts[index] - -describe("toast story: show and lifetime", () => { - test("showing a toast puts it at the front and starts its 4s timer", () => { - const first = show(init(), { kind: "error", title: "Couldn't send" }) - const second = show(first.model, { title: "Retrying" }) - expect(second.id).toBe(2) - expect(second.model.toasts.map((item) => item.title)).toEqual(["Retrying", "Couldn't send"]) - expect(toastAt(second.model, 0)).toMatchObject({ kind: null, duration: 4000, remainingMs: 4000 }) - expect(commandsOf(second)).toEqual([{ name: StartToastTimer.name, args: { id: 2, version: 1 } }]) - }) - - test("loading and infinite toasts never start a timer", () => { - expect(commandsOf(show(init(), { kind: "loading", title: "Uploading" }))).toEqual([]) - expect(commandsOf(show(init(), { title: "Pinned", duration: Number.POSITIVE_INFINITY }))).toEqual([]) - }) - - test("a started timer waits out the lifetime, then removes the toast after its exit animation", () => { - story( - update, - given(one), - message(Message.StartedTimer({ id: 1, version: 1, at: 1000 })), - Command.expectExact(WaitForToastLifetime({ id: 1, version: 1, ms: 4000 })), - Command.resolve(WaitForToastLifetime, Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.expectExact(WaitForToastRemoval({ id: 1 })), - model((next) => expect(toastAt(next, 0)?.isRemoved).toBe(true)), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), - model((next) => expect(next.toasts).toEqual([])), - ) - }) - - test("a lifetime that ends after a pause or update never dismisses the toast", () => { - story( - update, - given({ ...running, toasts: running.toasts.map((item) => ({ ...item, timerVersion: 2 })) }), - message(Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.expectNone(), - model((next) => expect(toastAt(next, 0)?.isRemoved).toBe(false)), - ) - }) - - test("showing with a known id updates the toast in place and restarts its timer", () => { - const loading = show(init(), { kind: "loading", title: "Saving", isPromise: true }) - const saved = show(loading.model, { id: loading.id, kind: "success", title: "Saved" }) - expect(saved.model.toasts).toHaveLength(1) - expect(toastAt(saved.model, 0)).toMatchObject({ kind: "success", title: "Saved", isPromise: true }) - expect(commandsOf(saved)).toEqual([{ name: StartToastTimer.name, args: { id: 1, version: 1 } }]) - }) - - test("dismissing keeps the toast's offset for its exit animation, then drops it", () => { - const measured = update(two, Message.MeasuredToast({ id: 1, height: 50 })).model - const withHeights = update(measured, Message.MeasuredToast({ id: 2, height: 40 })).model - const older = toastAt(withHeights, 1) - expect(older === undefined ? null : offsetOf(withHeights, older)).toBe(54) - const dismissed = dismiss(withHeights, 1) - expect(toastAt(dismissed.model, 1)).toMatchObject({ isRemoved: true, offsetBeforeRemove: 54 }) - expect(dismissed.model.heights).toEqual([{ toastId: 2, height: 40 }]) - expect(commandsOf(dismissed)).toEqual([{ name: WaitForToastRemoval.name, args: { id: 1 } }]) - }) +const payload = (overrides: Partial<Payload> = {}): Payload => ({ + kind: "success", + title: "Saved", + maybeDescription: Option.none(), + maybeAction: Option.none(), + maybeKey: Option.none(), + ...overrides, }) -// `running` hovered at t=2500: expanded, 2500ms left, timer version 2. -const paused: Model = { - ...running, - isExpanded: true, - toasts: running.toasts.map((item) => ({ - ...item, - remainingMs: 2500, - timerStartedAt: null, - timerVersion: 2, - })), -} +const entryId = "toaster-entry-0" -describe("toast story: pausing", () => { - test("hovering the toaster expands the stack and pauses every timer", () => { - story( - update, - given(running), - message(Message.HoveredToaster()), - Command.expectExact(PauseToastTimers()), - Command.resolve(PauseToastTimers, Message.PausedTimers({ at: 2500 })), - model((next) => { - expect(next.isExpanded).toBe(true) - expect(toastAt(next, 0)).toMatchObject({ - remainingMs: 2500, - timerStartedAt: null, - timerVersion: 2, - }) - }), - ) +describe("toast show", () => { + test("a toast takes the kit variant of its kind and the 4s default lifetime", () => { + const shown = show(init(), payload({ kind: "warning" }), false) + expect(shown.model.entries[0]).toMatchObject({ id: entryId, variant: "Warning" }) + expect(shown.model.entries[0]?.maybeDuration).toEqual(Option.some(shown.model.defaultDuration)) + expect(shown.commands?.map((command) => command.name)).toContain(UiToast.WaitBeforeDismissal.name) }) - test("leaving the toaster resumes each timer with only the time that was left", () => { - story( - update, - given(paused), - message(Message.LeftToaster()), - Command.expectExact(StartToastTimer({ id: 1, version: 3 })), - Command.resolve(StartToastTimer, Message.StartedTimer({ id: 1, version: 3, at: 9000 })), - Command.expectExact(WaitForToastLifetime({ id: 1, version: 3, ms: 2500 })), - Command.resolve(WaitForToastLifetime, Message.CompletedWaitForLifetime({ id: 1, version: 3 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), - model((next) => expect(next).toMatchObject({ isExpanded: false, toasts: [] })), - ) - }) - - test("a press that drags out of the toaster keeps it expanded until the pointer leaves again", () => { - story( - update, - given(paused), - message(Message.PressedToaster()), - Command.expectNone(), - message(Message.LeftToaster()), - model((next) => expect(next).toMatchObject({ isExpanded: true, isInteracting: true })), - message(Message.ReleasedToaster()), - Command.expectNone(), - model((next) => expect(next).toMatchObject({ isExpanded: true, isInteracting: false })), - ) - }) - - test("a hidden tab pauses the timers and a visible one resumes them", () => { - story( - update, - given(running), - message(Message.ChangedVisibility({ isHidden: true })), - Command.resolve(PauseToastTimers, Message.PausedTimers({ at: 4000 })), - model((next) => expect(toastAt(next, 0)?.remainingMs).toBe(1000)), - message(Message.ChangedVisibility({ isHidden: false })), - Command.expectExact(StartToastTimer({ id: 1, version: 3 })), - Command.resolve(StartToastTimer, Message.StartedTimer({ id: 1, version: 3, at: 5000 })), - Command.expectExact(WaitForToastLifetime({ id: 1, version: 3, ms: 1000 })), - Command.resolve(WaitForToastLifetime, Message.CompletedWaitForLifetime({ id: 1, version: 3 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), - ) - }) - - test("alt+T expands the stack, pauses the timers and focuses the toaster", () => { - story( - update, - given(running), - message(Message.PressedHotkey()), - Command.expectExact(PauseToastTimers(), FocusToaster()), - Command.resolve(PauseToastTimers, Message.PausedTimers({ at: 1500 })), - Command.resolve(FocusToaster, Message.CompletedFocusToaster()), - model((next) => expect(next.isExpanded).toBe(true)), - ) - }) - - test("Escape inside the toaster collapses the stack and resumes the timers", () => { - story( - update, - given(paused), - message(Message.PressedEscape()), - Command.expectExact(StartToastTimer({ id: 1, version: 3 })), - model((next) => expect(next.isExpanded).toBe(false)), - Command.resolve(StartToastTimer, Message.StartedTimer({ id: 1, version: 3, at: 0 })), - Command.resolve(WaitForToastLifetime, Message.CompletedWaitForLifetime({ id: 1, version: 3 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), + test("loading and sticky toasts have no lifetime", () => { + const loading = show(init(), payload({ kind: "loading" }), false) + expect(loading.model.entries[0]).toMatchObject({ variant: "Info", maybeDuration: Option.none() }) + expect(loading.commands?.map((command) => command.name)).not.toContain( + UiToast.WaitBeforeDismissal.name, ) + const sticky = show(init(), payload(), true) + expect(sticky.model.entries[0]?.maybeDuration).toEqual(Option.none()) + }) + + test("a toast with a live key replaces it in place (loading, then success)", () => { + const key = Option.some("save") + const loading = show(init(), payload({ kind: "loading", title: "Saving...", maybeKey: key }), false) + const done = show(loading.model, payload({ title: "Saved", maybeKey: key }), false) + expect(done.model.entries).toHaveLength(1) + expect(done.model.entries[0]).toMatchObject({ + id: entryId, + variant: "Success", + pendingDismissVersion: 1, + payload: { kind: "success", title: "Saved" }, + }) + expect(done.commands?.map((command) => command.args)).toEqual([ + { entryId, version: 1, duration: done.model.defaultDuration }, + ]) + const other = show(done.model, payload({ title: "Other" }), false) + expect(other.model.entries.map((entry) => entry.payload.title)).toEqual(["Saved", "Other"]) + }) + + test("replacing a hovered toast leaves its timer paused", () => { + const key = Option.some("save") + const loading = show(init(), payload({ kind: "loading", maybeKey: key }), false).model + const hovered = update(loading, Message.HoveredEntry({ entryId })).model + const done = show(hovered, payload({ maybeKey: key }), false) + expect(done.commands).toEqual([]) }) }) -describe("toast story: actions and removal", () => { - test("the action button removes its toast and tells the parent which one", () => { - story( - update, - given(two), - message(Message.ClickedAction({ id: 2 })), - expectOutMessage(OutMessage.ClickedAction({ id: 2 })), - Command.expectExact(WaitForToastRemoval({ id: 2 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 2 })), - expectNoOutMessage(), - model((next) => expect(next.toasts.map((item) => item.id)).toEqual([1])), - ) - }) - - test("removing down to one toast collapses the stack and resumes its timer", () => { - const expanded: Model = { ...two, isExpanded: true } - story( - update, - given(expanded), - message(Message.ClickedAction({ id: 2 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 2 })), - Command.expectExact(StartToastTimer({ id: 1, version: 2 })), - model((next) => expect(next.isExpanded).toBe(false)), - Command.resolve(StartToastTimer, Message.StartedTimer({ id: 1, version: 2, at: 0 })), - Command.resolve(WaitForToastLifetime, Message.CompletedWaitForLifetime({ id: 1, version: 2 })), - Command.resolve(WaitForToastRemoval, Message.CompletedWaitForRemoval({ id: 1 })), - model((next) => expect(next.toasts).toEqual([])), - ) - }) - - test("measuring a toast marks it mounted and records its height newest first", () => { +describe("toast lifecycle", () => { + test("hovering pauses the lifetime; leaving restarts it with a fresh version", () => { story( update, - given(two), - message(Message.MeasuredToast({ id: 1, height: 50 })), - message(Message.MeasuredToast({ id: 2, height: 40 })), - message(Message.MeasuredToast({ id: 2, height: 64 })), + given(show(init(), payload(), false).model), + message(Message.HoveredEntry({ entryId })), Command.expectNone(), - model((next) => { - expect(next.heights).toEqual([ - { toastId: 2, height: 64 }, - { toastId: 1, height: 50 }, - ]) - expect(toastAt(next, 0)).toMatchObject({ isMounted: true, initialHeight: 64 }) - }), + message(Message.LeftEntry({ entryId })), + Command.expectExact(UiToast.WaitBeforeDismissal), + // The timer started before the hover is stale and does nothing. + Command.resolve( + UiToast.WaitBeforeDismissal, + Message.CompletedWaitBeforeDismissal({ entryId, version: 0 }), + ), + model((m) => expect(m.entries[0]?.animation.isShowing).toBe(true)), ) }) }) - -describe("toast subscription gating", () => { - test("the hotkey and Escape listener only runs while there are toasts", () => { - const { modelToDependencies } = subscriptions.keys - expect(modelToDependencies(init()).hasToasts).toBe(false) - expect(modelToDependencies(one).hasToasts).toBe(true) - }) -}) diff --git a/apps/web-foldkit/src/ui/toast.ts b/apps/web-foldkit/src/ui/toast.ts index 1f2a7051b..e8ac94824 100644 --- a/apps/web-foldkit/src/ui/toast.ts +++ b/apps/web-foldkit/src/ui/toast.ts @@ -1,456 +1,107 @@ -import { Array, Duration, Effect, Option, Queue, Schema, Stream } from "effect" -import { Command, Mount, Subscription, type Update } from "foldkit" -import * as Dom from "foldkit/dom" -import { defineMessageUnion } from "foldkit/message" +import { Toast as UiToast } from "@foldkit/ui" +import { Array, Match, Option, Schema } from "effect" +import type { Update } from "foldkit" import { modifyFields } from "foldkit/struct" -import { sonnerCss } from "~/components/ui/sonner.styles" +import { ToastAction } from "../overlay/toasts" /** - * Port of `components/ui/toast.tsx` (sonner's Toaster with rich colors): the queue, stacking, - * expand on hover, timers and dismissal. The view, in `toast-view.ts`, renders sonner's DOM. - * Not ported: swipe to dismiss and focus restoration when focus leaves the toaster. + * The `@foldkit/ui` Toast bound to the app's payload. The kit owns each entry's lifecycle: the + * enter and exit Animation, the auto-dismiss timer (paused while hovered), swipe to dismiss and the + * `status`/`alert` role. The payload is what the view draws. `toast-view.ts` renders it. */ -// sonner injects its stylesheet into <head> when its module loads; so does this port (DOM only). -if (typeof document !== "undefined") { - const sonnerStyle = document.createElement("style") - sonnerStyle.type = "text/css" - sonnerStyle.appendChild(document.createTextNode(sonnerCss)) - document.head.appendChild(sonnerStyle) -} - // MODEL export const Kind = Schema.Literals(["success", "info", "warning", "error", "loading"]) export type Kind = typeof Kind.Type -export const Item = Schema.Struct({ - id: Schema.Number, - /** `null` for a plain `toast()`, which renders no `data-type`. */ - kind: Schema.NullOr(Kind), - title: Schema.String, - description: Schema.NullOr(Schema.String), - actionLabel: Schema.NullOr(Schema.String), - isPromise: Schema.Boolean, - /** Milliseconds, `null` for an infinite duration. */ - duration: Schema.NullOr(Schema.Number), - remainingMs: Schema.Number, - isMounted: Schema.Boolean, - isRemoved: Schema.Boolean, - initialHeight: Schema.Number, - offsetBeforeRemove: Schema.Number, - /** Bumped on every timer start or pause, so a stale lifetime never dismisses. */ - timerVersion: Schema.Number, - timerStartedAt: Schema.NullOr(Schema.Number), -}) -export type Item = typeof Item.Type - -const Height = Schema.Struct({ toastId: Schema.Number, height: Schema.Number }) - -export const Model = Schema.Struct({ - /** Newest first: index 0 is the front toast. */ - toasts: Schema.Array(Item), - /** Measured heights, newest measurement first (sonner's `heights`). */ - heights: Schema.Array(Height), - isExpanded: Schema.Boolean, - isInteracting: Schema.Boolean, - isDocumentHidden: Schema.Boolean, - nextId: Schema.Number, -}) -export type Model = typeof Model.Type - -export const init = (): Model => ({ - toasts: [], - heights: [], - isExpanded: false, - isInteracting: false, - isDocumentHidden: false, - nextId: 1, -}) - -/** sonner's defaults. */ -export const VISIBLE_TOASTS = 3 -export const GAP = 14 -const LIFETIME_MS = 4000 -const TIME_BEFORE_UNMOUNT = Duration.millis(200) - -// MESSAGE - -export const Message = defineMessageUnion({ - MeasuredToast: { id: Schema.Number, height: Schema.Number }, - HoveredToaster: {}, - LeftToaster: {}, - PressedToaster: {}, - ReleasedToaster: {}, - ClickedAction: { id: Schema.Number }, - PressedHotkey: {}, - PressedEscape: {}, - ChangedVisibility: { isHidden: Schema.Boolean }, - StartedTimer: { id: Schema.Number, version: Schema.Number, at: Schema.Number }, - PausedTimers: { at: Schema.Number }, - CompletedWaitForLifetime: { id: Schema.Number, version: Schema.Number }, - CompletedWaitForRemoval: { id: Schema.Number }, - CompletedFocusToaster: {}, -}) -export type Message = typeof Message.Type - -export const OutMessage = defineMessageUnion({ - ClickedAction: { id: Schema.Number }, -}) -export type OutMessage = typeof OutMessage.Type - -// COMMAND - -export const StartToastTimer = Command.define("StartToastTimer", { - args: { id: Schema.Number, version: Schema.Number }, - messages: [Message.StartedTimer], - execute: ({ id, version }) => Effect.sync(() => Message.StartedTimer({ id, version, at: Date.now() })), -}) - -export const PauseToastTimers = Command.define("PauseToastTimers", { - messages: [Message.PausedTimers], - execute: Effect.sync(() => Message.PausedTimers({ at: Date.now() })), -}) +/** A button that reports `key` to the root and dismisses the toast (sonner's `action`). */ +export const Action = Schema.Struct({ label: Schema.String, key: ToastAction }) +export type Action = typeof Action.Type -export const WaitForToastLifetime = Command.define("WaitForToastLifetime", { - args: { id: Schema.Number, version: Schema.Number, ms: Schema.Number }, - messages: [Message.CompletedWaitForLifetime], - execute: ({ id, version, ms }) => - Effect.sleep(Duration.millis(ms)).pipe(Effect.as(Message.CompletedWaitForLifetime({ id, version }))), -}) - -export const WaitForToastRemoval = Command.define("WaitForToastRemoval", { - args: { id: Schema.Number }, - messages: [Message.CompletedWaitForRemoval], - execute: ({ id }) => - Effect.sleep(TIME_BEFORE_UNMOUNT).pipe(Effect.as(Message.CompletedWaitForRemoval({ id }))), -}) - -export const FocusToaster = Command.define("FocusToaster", { - messages: [Message.CompletedFocusToaster], - execute: Dom.focus("[data-sonner-toaster]").pipe( - Effect.ignore, - Effect.as(Message.CompletedFocusToaster()), - ), -}) - -// MOUNT - -/** sonner measures a toast at mount and whenever its content changes, with `height: auto`. */ -const naturalHeight = (element: HTMLElement) => { - const original = element.style.height - element.style.height = "auto" - const height = element.getBoundingClientRect().height - element.style.height = original - return height -} - -export const MeasureToast = Mount.defineStream("MeasureToast", { - args: { id: Schema.Number }, - messages: [Message.MeasuredToast], - execute: ({ element, id }) => - Stream.callback<typeof Message.MeasuredToast.Type>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - if (!(element instanceof HTMLElement)) return () => undefined - const emit = () => - Queue.offerUnsafe( - queue, - Message.MeasuredToast({ id, height: naturalHeight(element) }), - ) - const observer = new MutationObserver(emit) - observer.observe(element, { childList: true, characterData: true, subtree: true }) - emit() - return () => observer.disconnect() - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ), +export const Payload = Schema.Struct({ + kind: Kind, + title: Schema.String, + maybeDescription: Schema.Option(Schema.String), + maybeAction: Schema.Option(Action), + /** A later toast with the same key replaces this one in place (sonner's `id`). */ + maybeKey: Schema.Option(Schema.String), }) - -// LAYOUT - -/** sonner's per-toast offset in the expanded stack (`heightIndex * gap + toastsHeightBefore`). */ -export const offsetOf = (model: Model, item: Item): number => { - if (item.isRemoved) return item.offsetBeforeRemove - const heightIndex = model.heights.findIndex((height) => height.toastId === item.id) - const before = model.heights.reduce( - (sum, height, index) => (index >= heightIndex ? sum : sum + height.height), - 0, +export type Payload = typeof Payload.Type + +export const Toast = UiToast.make(Payload) +export type Entry = typeof Toast.Entry.Type +export type Model = typeof Toast.Model.Type +export type Message = typeof Toast.Message.Type +export const Message = Toast.Message + +export const init = (): Model => Toast.init({ id: "toaster", swipeToDismiss: {} }) + +/** The kit's variant drives the ARIA role; a loading toast is a `status`, like info. */ +export const variantOf = (kind: Kind): UiToast.Variant => + Match.value(kind).pipe( + Match.withReturnType<UiToast.Variant>(), + Match.when("success", () => "Success"), + Match.when("info", () => "Info"), + Match.when("loading", () => "Info"), + Match.when("warning", () => "Warning"), + Match.when("error", () => "Error"), + Match.exhaustive, ) - return heightIndex * GAP + before -} // UPDATE type UpdateReturn = Update.Return<Model, Message> -const isPaused = (model: Model) => model.isExpanded || model.isInteracting || model.isDocumentHidden - -const hasTimer = (item: Item) => !item.isRemoved && item.duration !== null && item.kind !== "loading" +const isLeaving = (entry: Entry): boolean => + entry.animation.transitionState === "LeaveStart" || entry.animation.transitionState === "LeaveAnimating" -const mapItem = (model: Model, id: number, f: (item: Item) => Item): Model => - modifyFields(model, { toasts: Array.map((item) => (item.id === id ? f(item) : item)) }) - -/** Restarts one toast's timer (sonner's effect re-runs when the toast changes). */ -const restartTimer = (model: Model, id: number): UpdateReturn => { - const item = Array.findFirst(model.toasts, (candidate) => candidate.id === id) - if (Option.isNone(item) || !hasTimer(item.value) || isPaused(model)) return { model } - const version = item.value.timerVersion + 1 - return { - model: mapItem(model, id, (current) => ({ ...current, timerVersion: version, timerStartedAt: null })), - commands: [StartToastTimer({ id, version })], - } -} - -/** Pauses or resumes every timer when hover, press or visibility flips the paused state. */ -const syncPause = (before: Model, after: UpdateReturn): UpdateReturn => { - const model = after.model - const commands = after.commands ?? [] - if (isPaused(before) === isPaused(model)) return after - if (isPaused(model)) - return { - model: modifyFields(model, { - toasts: Array.map((item) => ({ ...item, timerVersion: item.timerVersion + 1 })), - }), - commands: [...commands, PauseToastTimers()], - } - return model.toasts.reduce<UpdateReturn>( - (result, item) => { - const next = restartTimer(result.model, item.id) - return { model: next.model, commands: [...(result.commands ?? []), ...(next.commands ?? [])] } - }, - { model, commands }, +const liveEntryWithKey = (model: Model, maybeKey: Option.Option<string>): Option.Option<Entry> => + Option.flatMap(maybeKey, (key) => + Array.findFirst( + model.entries, + (entry) => !isLeaving(entry) && Option.contains(entry.payload.maybeKey, key), + ), ) -} - -/** sonner collapses the stack whenever one toast or none is left. */ -const collapseWhenAlone = (model: Model): Model => - model.toasts.length <= 1 ? modifyFields(model, { isExpanded: () => false }) : model -const deleteToast = (model: Model, id: number): UpdateReturn => { - const item = Array.findFirst(model.toasts, (candidate) => candidate.id === id) - if (Option.isNone(item) || item.value.isRemoved) return { model } - const offset = offsetOf(model, item.value) +/** Swaps an entry's content and restarts its timer; the version bump makes any running timer stale. */ +const replace = (model: Model, entry: Entry, payload: Payload, isSticky: boolean): UpdateReturn => { + const next = modifyFields(entry, { + variant: () => variantOf(payload.kind), + payload: () => payload, + maybeDuration: () => (isSticky ? Option.none() : Option.some(model.defaultDuration)), + pendingDismissVersion: (version) => version + 1, + }) + const isTimerPaused = next.isHovered || UiToast.SwipeState.guards.Dragging(next.swipeState) return { - model: modifyFields( - mapItem(model, id, (current) => ({ ...current, isRemoved: true, offsetBeforeRemove: offset })), - { - heights: Array.filter((height) => height.toastId !== id), - }, - ), - commands: [WaitForToastRemoval({ id })], - } -} - -export const update = ( - model: Model, - message: Message, -): Update.ReturnWithOutMessage<Model, Message, OutMessage> => - Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, { - MeasuredToast: ({ id, height }) => { - const isKnown = model.heights.some((entry) => entry.toastId === id) - const heights = isKnown - ? Array.map(model.heights, (entry) => (entry.toastId === id ? { ...entry, height } : entry)) - : [{ toastId: id, height }, ...model.heights] - const item = Array.findFirst(model.toasts, (candidate) => candidate.id === id) - if (Option.isNone(item) || item.value.isRemoved) return { model } - return { - model: modifyFields( - mapItem(model, id, (current) => ({ ...current, isMounted: true, initialHeight: height })), - { heights: () => heights }, - ), - } - }, - HoveredToaster: () => syncPause(model, { model: modifyFields(model, { isExpanded: () => true }) }), - LeftToaster: () => - model.isInteracting - ? { model } - : syncPause(model, { model: modifyFields(model, { isExpanded: () => false }) }), - PressedToaster: () => syncPause(model, { model: modifyFields(model, { isInteracting: () => true }) }), - ReleasedToaster: () => - syncPause(model, { model: modifyFields(model, { isInteracting: () => false }) }), - ClickedAction: ({ id }) => ({ - ...deleteToast(model, id), - outMessage: OutMessage.ClickedAction({ id }), + model: modifyFields(model, { + entries: Array.map((current) => (current.id === entry.id ? next : current)), }), - PressedHotkey: () => { - const next = syncPause(model, { model: modifyFields(model, { isExpanded: () => true }) }) - return { model: next.model, commands: [...(next.commands ?? []), FocusToaster()] } - }, - PressedEscape: () => syncPause(model, { model: modifyFields(model, { isExpanded: () => false }) }), - ChangedVisibility: ({ isHidden }) => - syncPause(model, { model: modifyFields(model, { isDocumentHidden: () => isHidden }) }), - StartedTimer: ({ id, version, at }) => { - const item = Array.findFirst(model.toasts, (candidate) => candidate.id === id) - if (Option.isNone(item) || item.value.timerVersion !== version) return { model } - return { - model: mapItem(model, id, (current) => ({ ...current, timerStartedAt: at })), - commands: [WaitForToastLifetime({ id, version, ms: item.value.remainingMs })], - } - }, - PausedTimers: ({ at }) => ({ - model: modifyFields(model, { - toasts: Array.map((item) => - item.timerStartedAt === null - ? item - : { - ...item, - remainingMs: item.remainingMs - (at - item.timerStartedAt), - timerStartedAt: null, - }, + commands: isTimerPaused + ? [] + : Array.map(Array.fromOption(next.maybeDuration), (duration) => + UiToast.WaitBeforeDismissal({ + entryId: next.id, + version: next.pendingDismissVersion, + duration, + }), ), - }), - }), - CompletedWaitForLifetime: ({ id, version }) => { - const item = Array.findFirst(model.toasts, (candidate) => candidate.id === id) - return Option.isSome(item) && item.value.timerVersion === version - ? deleteToast(model, id) - : { model } - }, - CompletedWaitForRemoval: ({ id }) => - syncPause(model, { - model: collapseWhenAlone( - modifyFields(model, { toasts: Array.filter((item) => item.id !== id) }), - ), - }), - CompletedFocusToaster: () => ({ model }), - }) - -// API - -export interface ShowOptions { - /** Updates the toast with this id instead of adding one (sonner's `id` option). */ - readonly id?: number - readonly kind?: Kind | null - readonly title: string - readonly description?: string - readonly actionLabel?: string | null - /** A `toast.promise` toast keeps `data-promise` after it resolves. */ - readonly isPromise?: boolean - /** Milliseconds; `Infinity` keeps the toast until it is dismissed. */ - readonly duration?: number -} - -const durationOf = (duration: number | undefined) => - duration === Number.POSITIVE_INFINITY ? null : (duration ?? LIFETIME_MS) - -/** sonner's `toast()`/`toast.success()`/...: adds a toast, or updates the one with `options.id`. */ -export const show = (model: Model, options: ShowOptions): UpdateReturn & { readonly id: number } => { - const id = options.id ?? model.nextId - const existing = Array.findFirst(model.toasts, (item) => item.id === id) - if (Option.isSome(existing)) { - const duration = - options.duration === undefined ? existing.value.duration : durationOf(options.duration) - const updated = mapItem(model, id, (item) => ({ - ...item, - kind: options.kind === undefined ? item.kind : options.kind, - title: options.title, - description: options.description ?? item.description, - actionLabel: options.actionLabel === undefined ? item.actionLabel : options.actionLabel, - isPromise: item.isPromise || (options.isPromise ?? false), - duration, - remainingMs: duration === item.duration ? item.remainingMs : (duration ?? 0), - })) - return { ...restartTimer(updated, id), id } - } - const duration = durationOf(options.duration) - const item: Item = { - id, - kind: options.kind ?? null, - title: options.title, - description: options.description ?? null, - actionLabel: options.actionLabel ?? null, - isPromise: options.isPromise ?? false, - duration, - remainingMs: duration ?? 0, - isMounted: false, - isRemoved: false, - initialHeight: 0, - offsetBeforeRemove: 0, - timerVersion: 0, - timerStartedAt: null, } - const added = collapseWhenAlone( - modifyFields(model, { - toasts: (toasts) => [item, ...toasts], - nextId: (nextId) => Math.max(nextId, id + 1), - }), - ) - return { ...syncPause(model, restartTimer(added, id)), id } } /** - * A serializable toast request, for Messages and OutMessages (a page's `RequestedToast`). - * `durationMs` omitted means sonner's 4s default; `null` keeps the toast until dismissed. + * Shows a toast, or replaces the live toast with the same `maybeKey` in place (loading, then + * success). A sticky toast stays until it is dismissed; loading toasts are always sticky. */ -export const Request = Schema.Struct({ - kind: Schema.NullOr(Kind), - title: Schema.String, - description: Schema.optionalKey(Schema.String), - actionLabel: Schema.optionalKey(Schema.String), - durationMs: Schema.optionalKey(Schema.NullOr(Schema.Number)), -}) -export type Request = typeof Request.Type - -/** `show` for a Request: what a root `toasts` slot calls when a page asks for a toast. */ -export const showRequest = (model: Model, request: Request): UpdateReturn & { readonly id: number } => - show(model, { - kind: request.kind, - title: request.title, - ...(request.description === undefined ? {} : { description: request.description }), - ...(request.actionLabel === undefined ? {} : { actionLabel: request.actionLabel }), - ...(request.durationMs === undefined - ? {} - : { duration: request.durationMs ?? Number.POSITIVE_INFINITY }), +export const show = (model: Model, payload: Payload, isSticky: boolean): UpdateReturn => { + const sticky = isSticky || payload.kind === "loading" + return Option.match(liveEntryWithKey(model, payload.maybeKey), { + onSome: (entry) => replace(model, entry, payload, sticky), + onNone: () => { + const shown = Toast.show(model, { payload, variant: variantOf(payload.kind), sticky }) + return { model: shown.model, commands: shown.commands ?? [] } + }, }) +} -/** The root's page request (`overlay/toasts.ts` ToastRequest: `intent`, nullable `description`). */ -export const fromIntentRequest = (request: { - readonly intent: Kind - readonly title: string - readonly description: string | null -}): Request => ({ - kind: request.intent, - title: request.title, - ...(request.description === null ? {} : { description: request.description }), -}) - -/** sonner's `toast.dismiss(id)`. */ -export const dismiss = (model: Model, id: number): UpdateReturn => deleteToast(model, id) - -// SUBSCRIPTION - -const isInsideToaster = () => document.activeElement?.closest("[data-sonner-toaster]") != null - -export const subscriptions = Subscription.make<Model, Message>()((entry) => ({ - // The hotkey and Escape only act on a rendered stack, so keydown is observed while toasts exist. - keys: entry( - { hasToasts: Schema.Boolean }, - { - modelToDependencies: (model) => ({ hasToasts: model.toasts.length > 0 }), - dependenciesToStream: ({ hasToasts }) => - !hasToasts - ? Stream.empty - : Dom.streamFromEventFilterMap({ - target: document, - type: "keydown", - filterMapEvent: (event) => - event.altKey && event.code === "KeyT" - ? Option.some(Message.PressedHotkey()) - : event.code === "Escape" && isInsideToaster() - ? Option.some(Message.PressedEscape()) - : Option.none(), - }), - }, - ), - visibility: Subscription.persistentEntry( - Stream.suspend(() => - Dom.streamFromEvent({ - target: document, - type: "visibilitychange", - mapEvent: () => Message.ChangedVisibility({ isHidden: document.hidden }), - }), - ), - ), -})) +export const { update, dismiss, subscriptions } = Toast diff --git a/bun.lock b/bun.lock index ff317ba51..cecd9e18e 100644 --- a/bun.lock +++ b/bun.lock @@ -276,7 +276,6 @@ "prosemirror-view": "^1.42.6", "remend": "^1.2.2", "rivetkit": "2.1.6", - "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", }, @@ -3094,8 +3093,6 @@ "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], - "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "sorted-btree": ["sorted-btree@1.8.1", "", {}, "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], From 34bb57d41dc4fc30c085333c930bf1bf18f169d8 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:05:47 +0200 Subject: [PATCH 47/52] refactor(web): tooltips on @foldkit/ui Tooltip; delete the global modality Trigger handlers live in the view, so Scene tests can hover and focus a tooltip. Anchor (floating-ui) positions the panel; the arrow reads --arrow-x/--arrow-y. The module-level input modality had no other consumer and is gone. toAnchorPlacement maps the app's placements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/chat/tooltip-host.ts | 19 +- .../src/core/components/ui/tooltip.styles.ts | 41 +- .../page/chat/channel/messages.scene.test.ts | 12 +- apps/web-foldkit/src/page/chat/overlays.ts | 4 +- apps/web-foldkit/src/test/chat-fixtures.ts | 21 +- apps/web-foldkit/src/ui/aria/interaction.ts | 26 -- apps/web-foldkit/src/ui/aria/placement.ts | 33 ++ apps/web-foldkit/src/ui/tooltip.scene.test.ts | 100 ++--- apps/web-foldkit/src/ui/tooltip.story.test.ts | 167 ++++---- apps/web-foldkit/src/ui/tooltip.ts | 379 +++++------------- 10 files changed, 299 insertions(+), 503 deletions(-) diff --git a/apps/web-foldkit/src/chat/tooltip-host.ts b/apps/web-foldkit/src/chat/tooltip-host.ts index 06e8413e7..80355bdd9 100644 --- a/apps/web-foldkit/src/chat/tooltip-host.ts +++ b/apps/web-foldkit/src/chat/tooltip-host.ts @@ -1,8 +1,8 @@ import { MessageId } from "@hazel/schema" -import { Schema } from "effect" +import { Duration, Schema } from "effect" import { Command, type Update } from "foldkit" import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" -import type { Placement } from "../ui/aria/position" +import type { Placement } from "../ui/aria/placement" import * as Tooltip from "../ui/tooltip" /** @@ -22,9 +22,14 @@ export const Message = Schema.Struct({ }) export type Message = typeof Message.Type -/** Messages that move the host to a new trigger; the rest only apply to the current one. */ +/** + * Messages that move the host to a new trigger; the rest only apply to the current one. A press + * moves it too, so the focus that follows a mouse press finds the press and keeps the tooltip shut. + */ const isActivating = (message: Tooltip.Message) => - message._tag === "HoveredTrigger" || message._tag === "FocusedTrigger" + message._tag === "EnteredTrigger" || + message._tag === "FocusedTrigger" || + message._tag === "PressedPointerOnTrigger" export const update = <Wrapped>( model: Model, @@ -35,7 +40,7 @@ export const update = <Wrapped>( 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) @@ -54,8 +59,8 @@ export const update = <Wrapped>( const closedModels = new Map<string, Tooltip.Model>() 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 } diff --git a/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts b/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts index 7baae8f98..59951c476 100644 --- a/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/tooltip.styles.ts @@ -1,34 +1,45 @@ import { tv } from "tailwind-variants" +/** + * The panel fades and slides in from the trigger as it mounts (`starting:`, @starting-style). The + * slide follows the requested `side`: Anchor writes the resolved side to `data-placement` only + * after the first position, too late for the starting style. + */ export const tooltipStyles = tv({ base: [ - "group max-w-sm origin-(--trigger-anchor-point) rounded-lg border border-(--tooltip-border) px-2.5 py-1.5 text-sm/6 will-change-transform [--tooltip-border:var(--color-muted-fg)]/30 dark:shadow-none *:[strong]:font-medium", + "group z-50 max-w-sm rounded-lg border border-(--tooltip-border) px-2.5 py-1.5 text-sm/6 [--tooltip-border:var(--color-muted-fg)]/30 dark:shadow-none *:[strong]:font-medium", + "transition-[opacity,translate] duration-150 ease-out starting:opacity-0", ], variants: { inverse: { true: ["border-transparent bg-fg text-bg", "**:[.text-muted-fg]:text-bg/60"], false: "bg-overlay text-overlay-fg", }, - isEntering: { - true: [ - "fade-in animate-in", - "data-[placement=left]:slide-in-from-right-1 data-[placement=right]:slide-in-from-left-1 data-[placement=top]:slide-in-from-bottom-1 data-[placement=bottom]:slide-in-from-top-1", - ], - }, - isExiting: { - true: [ - "fade-in direction-reverse animate-in", - "data-[placement=left]:slide-out-to-right-1 data-[placement=right]:slide-out-to-left-1 data-[placement=top]:slide-out-to-bottom-1 data-[placement=bottom]:slide-out-to-top-1", - ], + side: { + top: "starting:translate-y-1", + bottom: "starting:-translate-y-1", + left: "starting:translate-x-1", + right: "starting:-translate-x-1", }, }, defaultVariants: { inverse: false, + side: "top", }, }) -/** Arrow classes, joined by `twJoin` with the inverse or default fill. */ -export const tooltipArrowBase = - "block group-data-[placement=bottom]:rotate-180 group-data-[placement=left]:-rotate-90 group-data-[placement=right]:rotate-90 forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]" +/** + * Arrow classes, joined by `twJoin` with the inverse or default fill. The arrow sits outside the + * panel edge facing the trigger, at the `--arrow-x`/`--arrow-y` offset Anchor publishes, and the + * side comes from the panel's `data-placement`. + */ +export const tooltipArrowBase = [ + "absolute block left-(--arrow-x) top-(--arrow-y)", + "group-data-[placement=top]:top-full", + "group-data-[placement=bottom]:bottom-full group-data-[placement=bottom]:rotate-180", + "group-data-[placement=left]:left-full group-data-[placement=left]:-rotate-90", + "group-data-[placement=right]:right-full group-data-[placement=right]:rotate-90", + "forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]", +].join(" ") export const tooltipArrowInverse = "fill-fg stroke-transparent" export const tooltipArrowDefault = "fill-overlay stroke-(--tooltip-border)" diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index e89bd6a61..625953dff 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -81,7 +81,7 @@ describe("hover toolbar", () => { channelScene, Scene.given(derivedModel()), mountedChannel(graceMessageId), - mountedToolbar(false), + mountedToolbar, Scene.click(Scene.role("button", { name: "React with 👍" })), Scene.Command.expectExact( ToggleReaction({ messageId: graceMessageId, channelId, emoji: "👍", userId: ada }), @@ -95,7 +95,7 @@ describe("hover toolbar", () => { channelScene, Scene.given(derivedModel()), mountedChannel(graceMessageId), - mountedToolbar(false), + mountedToolbar, Scene.expect(Scene.role("button", { name: "Reply" })).toExist(), Scene.expect(Scene.role("button", { name: "Edit message" })).toBeAbsent(), Scene.expect(Scene.role("button", { name: "Delete message" })).toBeAbsent(), @@ -107,7 +107,7 @@ describe("hover toolbar", () => { channelScene, Scene.given(derivedModel()), mountedChannel(graceMessageId), - mountedToolbar(false), + mountedToolbar, Scene.click(Scene.role("button", { name: "Reply" })), Scene.expect(Scene.text("Replying to")).toExist(), Scene.click(Scene.role("button", { name: "Cancel reply" })), @@ -120,7 +120,7 @@ describe("hover toolbar", () => { channelScene, Scene.given(derivedModel()), mountedChannel(adaMessageId), - mountedToolbar(true), + mountedToolbar, Scene.click(Scene.role("button", { name: "Edit message" })), Scene.expect(Scene.text("Editing message")).toExist(), Scene.Command.expectExact( @@ -142,7 +142,7 @@ describe("hover toolbar", () => { channelScene, Scene.given(derivedModel()), mountedChannel(adaMessageId), - mountedToolbar(true), + mountedToolbar, Scene.click(Scene.role("button", { name: "Delete message" })), Scene.Command.expectNone(), Scene.expectOutMessage( @@ -161,7 +161,7 @@ describe("threads", () => { channelScene, Scene.given(derivedModel()), mountedChannel(graceMessageId), - mountedToolbar(false), + mountedToolbar, Scene.pointerDown(Scene.role("button", { name: "More actions" })), Scene.Mount.resolve({ name: "PortalMenu" }, Menu.Message.CompletedPortalMenu()), Scene.click(Scene.role("menuitem", { name: "Reply in thread" })), diff --git a/apps/web-foldkit/src/page/chat/overlays.ts b/apps/web-foldkit/src/page/chat/overlays.ts index ead887ff7..9a50da8d7 100644 --- a/apps/web-foldkit/src/page/chat/overlays.ts +++ b/apps/web-foldkit/src/page/chat/overlays.ts @@ -366,8 +366,8 @@ export const update = (model: Model, message: Message): OverlaysReturn => const hoveredTrigger = nextTrigger( model.hoveredTrigger, tooltipMessage, - "HoveredTrigger", - "UnhoveredTrigger", + "EnteredTrigger", + "LeftTrigger", ) const focusedTrigger = nextTrigger( model.focusedTrigger, diff --git a/apps/web-foldkit/src/test/chat-fixtures.ts b/apps/web-foldkit/src/test/chat-fixtures.ts index 006b1f464..a34e34d80 100644 --- a/apps/web-foldkit/src/test/chat-fixtures.ts +++ b/apps/web-foldkit/src/test/chat-fixtures.ts @@ -5,7 +5,6 @@ import * as Draft from "../composer/draft" import { DragState, DropEvent } from "../composer/drop" import * as MessageList from "../mount/message-list" import * as Overlays from "../page/chat/overlays" -import * as Tooltip from "../ui/tooltip" import * as Menu from "../ui/menu" import { Message, type Model } from "../page/chat/channel/model" import { @@ -63,18 +62,8 @@ export const mountedChannel = (hovered: MessageId | null = null): Step => ], ) -/** Three quick reactions, Add reaction, Copy and Reply, plus Edit and Delete on one's own message. */ -const toolbarTooltips = (isOwnMessage: boolean) => (isOwnMessage ? 8 : 6) - -/** The hover toolbar's Mounts: its placement, one tooltip trigger per button and the More menu trigger. */ -const tooltipTrigger = (): readonly [{ readonly name: string }, Tooltip.Message] => [ - { name: "TrackTooltipTrigger" }, - Tooltip.Message.CompletedTrackTrigger(), -] - -export const mountedToolbar = (isOwnMessage: boolean): Step => - Scene.Mount.resolveAll( - [{ name: "PlaceMessageToolbar" }, overlays(Overlays.Message.EnteredToolbar())], - ...Array.from({ length: toolbarTooltips(isOwnMessage) }, tooltipTrigger), - [{ name: "FocusTriggerOnPress" }, Menu.Message.CompletedFocusTriggerOnPress()], - ) +/** The hover toolbar's Mounts: its placement and the More menu trigger (tooltip triggers have none). */ +export const mountedToolbar: Step = Scene.Mount.resolveAll( + [{ name: "PlaceMessageToolbar" }, overlays(Overlays.Message.EnteredToolbar())], + [{ name: "FocusTriggerOnPress" }, Menu.Message.CompletedFocusTriggerOnPress()], +) diff --git a/apps/web-foldkit/src/ui/aria/interaction.ts b/apps/web-foldkit/src/ui/aria/interaction.ts index a3fee9d25..2bdb06d29 100644 --- a/apps/web-foldkit/src/ui/aria/interaction.ts +++ b/apps/web-foldkit/src/ui/aria/interaction.ts @@ -245,32 +245,6 @@ export const subscriptions = Subscription.make<Model, Message>()(() => ({ ), })) -// GLOBAL MODALITY - -/** - * React Aria's module-level modality (useFocusVisible's `currentModality`), for code that reads it - * synchronously at event time, such as Mounts. Views read the Submodel's `modality` instead. - */ -let globalModality: Modality | null = null -let isGlobalModalityTracked = false - -export const trackGlobalModality = () => { - if (isGlobalModalityTracked) return - isGlobalModalityTracked = true - const toKeyboard = (event: KeyboardEvent) => { - if (isModalityKey(event) && !event.altKey) globalModality = "keyboard" - } - const toPointer = () => { - globalModality = "pointer" - } - document.addEventListener("keydown", toKeyboard, true) - document.addEventListener("keyup", toKeyboard, true) - document.addEventListener("pointerdown", toPointer, true) - document.addEventListener("pointerup", toPointer, true) -} - -export const currentGlobalModality = (): Modality | null => globalModality - // VIEW /** Every attribute here is valid on any element, textarea included (no innerHTML). */ diff --git a/apps/web-foldkit/src/ui/aria/placement.ts b/apps/web-foldkit/src/ui/aria/placement.ts index fe79446fa..cbace69fb 100644 --- a/apps/web-foldkit/src/ui/aria/placement.ts +++ b/apps/web-foldkit/src/ui/aria/placement.ts @@ -1,3 +1,4 @@ +import type { Placement as AnchorPlacement } from "@foldkit/ui/anchor" import { Schema } from "effect" import type { Placement as PositionPlacement } from "./position" @@ -29,3 +30,35 @@ const placements = [ export const Placement = Schema.Literals(placements) export type Placement = typeof Placement.Type + +/** + * The Floating UI placement `@foldkit/ui`'s Anchor takes for a React Aria placement. The cross + * axis maps to an alignment (`top left` → `top-start`, `left bottom` → `left-end`), and the logical + * sides read left-to-right (`start` → `left`, `end` → `right`). + */ +const ANCHOR_PLACEMENTS: Readonly<Record<Placement, AnchorPlacement>> = { + bottom: "bottom", + "bottom left": "bottom-start", + "bottom right": "bottom-end", + "bottom start": "bottom-start", + "bottom end": "bottom-end", + top: "top", + "top left": "top-start", + "top right": "top-end", + "top start": "top-start", + "top end": "top-end", + left: "left", + "left top": "left-start", + "left bottom": "left-end", + start: "left", + "start top": "left-start", + "start bottom": "left-end", + right: "right", + "right top": "right-start", + "right bottom": "right-end", + end: "right", + "end top": "right-start", + "end bottom": "right-end", +} + +export const toAnchorPlacement = (placement: Placement): AnchorPlacement => ANCHOR_PLACEMENTS[placement] diff --git a/apps/web-foldkit/src/ui/tooltip.scene.test.ts b/apps/web-foldkit/src/ui/tooltip.scene.test.ts index 0752af92a..c65fe42ce 100644 --- a/apps/web-foldkit/src/ui/tooltip.scene.test.ts +++ b/apps/web-foldkit/src/ui/tooltip.scene.test.ts @@ -1,19 +1,11 @@ // @vitest-environment jsdom +import { Duration } from "effect" import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" -import { - init, - Message, - PortalTooltip, - TrackTrigger, - update, - view, - WaitForTooltipHideDelay, - WaitForTooltipShowDelay, -} from "./tooltip" +import { AnchorTooltipPanel, init, Message, update, view, WaitBeforeShowing } from "./tooltip" -/** Tooltip through the view: hover and focus come from the TrackTrigger Mount's listeners. */ +/** Tooltip through the view: hover, focus, press and Escape come from the trigger's own handlers. */ const sceneView = Scene.withViewInputs(view, { toTrigger: (attributes, overlay) => ih.div([], [ih.button([...attributes], ["Add reaction"]), overlay]), @@ -23,25 +15,15 @@ const sceneView = Scene.withViewInputs(view, { const config = { update, view: sceneView() } const trigger = Scene.role("button", { name: "Add reaction" }) const tooltip = Scene.role("tooltip") -const tracked = [ - Scene.given(init("react")), - Scene.Mount.resolve(TrackTrigger, Message.CompletedTrackTrigger()), -] -const shownByFocus = [ - ...tracked, - Scene.Subscription.emit(Message.FocusedTrigger({ isFocusVisible: true })), - Scene.Mount.resolve(PortalTooltip, Message.CompletedPortalTooltip()), -] +const anchored = Scene.Mount.resolve(AnchorTooltipPanel, Message.CompletedAnchorTooltip()) +const shownByFocus = [Scene.given(init("react")), Scene.focus(trigger), anchored] describe("tooltip scene", () => { - test("the trigger tracks hover and focus, with no tooltip until one starts", () => { + test("no tooltip renders until the trigger is hovered or focused", () => { Scene.scene( config, Scene.given(init("react")), - Scene.Mount.expectExact(TrackTrigger()), - Scene.Mount.resolve(TrackTrigger, Message.CompletedTrackTrigger()), Scene.expect(trigger).toHaveId("react-trigger"), - Scene.expect(trigger).not.toHaveAttr("aria-describedby"), Scene.expect(tooltip).toBeAbsent(), ) }) @@ -49,16 +31,22 @@ describe("tooltip scene", () => { test("hovering shows the tooltip after the delay and describes the trigger", () => { Scene.scene( config, - ...tracked, - Scene.Subscription.emit(Message.HoveredTrigger({ isPointerModality: true })), + Scene.given(init("react")), + Scene.hover(trigger), Scene.expect(tooltip).toBeAbsent(), - Scene.Command.expectExact(WaitForTooltipShowDelay({ version: 1, delayMs: 1500 })), - Scene.Command.resolve(WaitForTooltipShowDelay, Message.CompletedWaitForShowDelay({ version: 1 })), - Scene.Mount.expectExact(PortalTooltip({ id: "react", placement: "top", offset: 10 })), - Scene.Mount.resolve(PortalTooltip, Message.CompletedPortalTooltip()), + Scene.Command.expectExact(WaitBeforeShowing({ delay: Duration.millis(1500), version: 1 })), + Scene.Command.resolve(WaitBeforeShowing, Message.CompletedWaitBeforeShowing({ version: 1 })), + Scene.Mount.expectExact( + AnchorTooltipPanel({ + buttonId: "react-trigger", + anchor: { placement: "top", gap: 10, padding: 8 }, + arrowId: "react-arrow", + }), + ), + anchored, Scene.expect(tooltip).toHaveText("React with an emoji"), - Scene.expect(tooltip).toHaveId("react-tooltip"), - Scene.expect(trigger).toHaveAttr("aria-describedby", "react-tooltip"), + Scene.expect(tooltip).toHaveId("react-panel"), + Scene.expect(trigger).toHaveAttr("aria-describedby", "react-panel"), Scene.expect(trigger).toHaveAccessibleDescription("React with an emoji"), ) }) @@ -67,50 +55,50 @@ describe("tooltip scene", () => { Scene.scene(config, ...shownByFocus, Scene.Command.expectNone(), Scene.expect(tooltip).toExist()) }) - test("Escape anywhere in the document hides the tooltip", () => { + test("focus from a mouse press does not show the tooltip", () => { Scene.scene( config, - ...shownByFocus, - Scene.Subscription.emit(Message.PressedEscape()), - Scene.Mount.expectEnded(PortalTooltip), + Scene.given(init("react")), + Scene.pointerDown(trigger), + Scene.focus(trigger), Scene.expect(tooltip).toBeAbsent(), - Scene.expect(trigger).not.toHaveAttr("aria-describedby"), ) }) - test("blurring the trigger hides the tooltip without a delay", () => { + test("Escape on the trigger hides the tooltip", () => { Scene.scene( config, ...shownByFocus, - Scene.Subscription.emit(Message.BlurredTrigger()), - Scene.Command.expectNone(), - Scene.Mount.expectEnded(PortalTooltip), + Scene.keydown(trigger, "Escape"), + Scene.Mount.expectEnded(AnchorTooltipPanel), Scene.expect(tooltip).toBeAbsent(), ) }) - test("leaving the trigger hides the tooltip after the hide delay", () => { + test("blurring the trigger hides the tooltip without a delay", () => { Scene.scene( config, - ...tracked, - Scene.Subscription.emit(Message.HoveredTrigger({ isPointerModality: true })), - Scene.Command.resolve(WaitForTooltipShowDelay, Message.CompletedWaitForShowDelay({ version: 1 })), - Scene.Mount.resolve(PortalTooltip, Message.CompletedPortalTooltip()), - Scene.Subscription.emit(Message.UnhoveredTrigger()), - Scene.expect(tooltip).toExist(), - Scene.Command.resolve(WaitForTooltipHideDelay, Message.CompletedWaitForHideDelay({ version: 2 })), - Scene.Mount.expectEnded(PortalTooltip), + ...shownByFocus, + Scene.blur(trigger), + Scene.Command.expectNone(), + Scene.Mount.expectEnded(AnchorTooltipPanel), Scene.expect(tooltip).toBeAbsent(), ) }) - test("an inverse tooltip without an arrow renders only its content", () => { + test("an inverse tooltip without an arrow renders only its content at the mapped placement", () => { Scene.scene( - { update, view: sceneView({ arrow: false, inverse: true, placement: "bottom" }) }, - ...tracked, - Scene.Subscription.emit(Message.FocusedTrigger({ isFocusVisible: true })), - Scene.Mount.expectExact(PortalTooltip({ id: "react", placement: "bottom", offset: 10 })), - Scene.Mount.resolve(PortalTooltip, Message.CompletedPortalTooltip()), + { update, view: sceneView({ arrow: false, inverse: true, placement: "end top" }) }, + Scene.given(init("react")), + Scene.focus(trigger), + Scene.Mount.expectExact( + AnchorTooltipPanel({ + buttonId: "react-trigger", + anchor: { placement: "right-start", gap: 10, padding: 8 }, + arrowId: "react-arrow", + }), + ), + anchored, Scene.expect(Scene.within(tooltip, Scene.selector("svg"))).toBeAbsent(), ) }) diff --git a/apps/web-foldkit/src/ui/tooltip.story.test.ts b/apps/web-foldkit/src/ui/tooltip.story.test.ts index 3b5bf5c31..e591dcb5c 100644 --- a/apps/web-foldkit/src/ui/tooltip.story.test.ts +++ b/apps/web-foldkit/src/ui/tooltip.story.test.ts @@ -1,30 +1,27 @@ // @vitest-environment jsdom +import { MessageId } from "@hazel/schema" +import { Duration } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { - init, - initWithDelay, - Message, - update, - WaitForTooltipHideDelay, - WaitForTooltipShowDelay, -} from "./tooltip" +import * as TooltipHost from "../chat/tooltip-host" +import { toAnchorPlacement } from "./aria/placement" +import { init, Message, update, WaitBeforeShowing } from "./tooltip" -/** React Aria TooltipTrigger: a 1500ms show delay on hover, immediate on keyboard focus, 500ms hide delay. */ +/** The kit Tooltip with the app's 1500ms default show delay; focus not from a mouse press shows it at once. */ const closed = init("rename") -const hoveredOpen = { ...closed, isOpen: true, isHovered: true, version: 1 } -const hover = message(Message.HoveredTrigger({ isPointerModality: true })) +const enter = message(Message.EnteredTrigger()) +const showAfterDelay = Command.resolve(WaitBeforeShowing, Message.CompletedWaitBeforeShowing({ version: 1 })) describe("tooltip story", () => { test("hovering the trigger waits the default 1500ms before showing the tooltip", () => { story( update, given(closed), - hover, - Command.expectExact(WaitForTooltipShowDelay({ version: 1, delayMs: 1500 })), + enter, + Command.expectExact(WaitBeforeShowing({ delay: Duration.millis(1500), version: 1 })), model((next) => expect(next.isOpen).toBe(false)), - Command.resolve(WaitForTooltipShowDelay, Message.CompletedWaitForShowDelay({ version: 1 })), + showAfterDelay, model((next) => expect(next.isOpen).toBe(true)), ) }) @@ -32,132 +29,120 @@ describe("tooltip story", () => { test("a trigger with its own delay waits that long instead", () => { story( update, - given(initWithDelay("rename", 300)), - hover, - Command.expectExact(WaitForTooltipShowDelay({ version: 1, delayMs: 300 })), - Command.resolve(WaitForTooltipShowDelay, Message.CompletedWaitForShowDelay({ version: 1 })), + given(init("rename", 300)), + enter, + Command.expectExact(WaitBeforeShowing({ delay: Duration.millis(300), version: 1 })), + showAfterDelay, model((next) => expect(next.isOpen).toBe(true)), ) }) - test("keyboard focus shows the tooltip immediately", () => { + test("leaving the trigger before the delay ends cancels the pending show", () => { story( update, - given(closed), - message(Message.FocusedTrigger({ isFocusVisible: true })), - Command.expectNone(), - model((next) => { - expect(next.isOpen).toBe(true) - expect(next.isFocused).toBe(true) - }), + given(update(closed, Message.EnteredTrigger()).model), + message(Message.LeftTrigger()), + message(Message.CompletedWaitBeforeShowing({ version: 1 })), + model((next) => expect(next.isOpen).toBe(false)), ) }) - test("focus that is not focus-visible (a pointer press) does not show the tooltip", () => { + test("leaving an open tooltip hides it at once", () => { story( update, given(closed), - message(Message.FocusedTrigger({ isFocusVisible: false })), + enter, + showAfterDelay, + message(Message.LeftTrigger()), Command.expectNone(), - model((next) => expect(next).toEqual(closed)), + model((next) => expect(next.isOpen).toBe(false)), ) }) - test("hovering without pointer modality never shows the tooltip once the delay ends", () => { + test("keyboard focus shows the tooltip immediately", () => { story( update, given(closed), - message(Message.HoveredTrigger({ isPointerModality: false })), - Command.resolve(WaitForTooltipShowDelay, Message.CompletedWaitForShowDelay({ version: 1 })), - model((next) => expect(next.isOpen).toBe(false)), + message(Message.FocusedTrigger()), + Command.expectNone(), + model((next) => expect(next.isOpen).toBe(true)), ) }) - test("leaving the trigger before the delay ends cancels the pending show", () => { + test("the focus a mouse press gives the trigger does not show the tooltip", () => { story( update, - given({ ...closed, isHovered: true, version: 1 }), - message(Message.UnhoveredTrigger()), - Command.expectNone(), - model((next) => expect(next.version).toBe(2)), - message(Message.CompletedWaitForShowDelay({ version: 1 })), + given(closed), + message(Message.PressedPointerOnTrigger({ pointerType: "mouse" })), + message(Message.FocusedTrigger()), model((next) => expect(next.isOpen).toBe(false)), ) }) - test("leaving an open tooltip waits the 500ms hide delay before closing", () => { + test("blurring the trigger hides the tooltip without a delay", () => { story( update, - given(hoveredOpen), - message(Message.UnhoveredTrigger()), - Command.expectExact(WaitForTooltipHideDelay({ version: 2 })), - model((next) => expect(next.isOpen).toBe(true)), - Command.resolve(WaitForTooltipHideDelay, Message.CompletedWaitForHideDelay({ version: 2 })), + given(closed), + message(Message.FocusedTrigger()), + message(Message.BlurredTrigger()), + Command.expectNone(), model((next) => expect(next.isOpen).toBe(false)), ) }) - test("re-entering during the hide delay keeps the warm tooltip open with no new delay", () => { + test("Escape hides the tooltip and keeps it shut until the pointer leaves", () => { story( update, - given({ ...hoveredOpen, isHovered: false, version: 2 }), - hover, + given(closed), + enter, + showAfterDelay, + message(Message.PressedEscape()), + model((next) => expect(next.isOpen).toBe(false)), + enter, Command.expectNone(), - model((next) => expect(next).toMatchObject({ isOpen: true, version: 3 })), - message(Message.CompletedWaitForHideDelay({ version: 2 })), + message(Message.LeftTrigger()), + enter, + Command.expectExact(WaitBeforeShowing({ delay: Duration.millis(1500), version: 4 })), + Command.resolve(WaitBeforeShowing, Message.CompletedWaitBeforeShowing({ version: 4 })), model((next) => expect(next.isOpen).toBe(true)), ) }) }) -describe("tooltip story: dismissal", () => { - test("blurring the trigger hides the tooltip immediately", () => { - story( - update, - given({ ...closed, isOpen: true, isFocused: true, version: 1 }), - message(Message.BlurredTrigger()), - Command.expectNone(), - model((next) => expect(next).toMatchObject({ isOpen: false, isFocused: false })), - ) +describe("tooltip host", () => { + const messageId = MessageId.make("00000000-0000-4000-8000-000000000001") + const hostMessage = (key: string, inner: Message): TooltipHost.Message => ({ + key, + messageId, + delayMs: 300, + message: inner, }) + const run = (model: TooltipHost.Model, key: string, inner: Message) => + TooltipHost.update(model, hostMessage(key, inner), (wrapped) => wrapped).model - test("pressing the trigger hides the tooltip immediately even while hovered", () => { - story( - update, - given(hoveredOpen), - message(Message.PressedTrigger()), - Command.expectNone(), - model((next) => expect(next).toMatchObject({ isOpen: false, isHovered: false })), - ) + test("entering a trigger moves the host to it with the trigger's delay", () => { + const next = run(null, "a", Message.EnteredTrigger()) + expect(next?.id).toBe("a") + expect(next === null ? null : Duration.toMillis(next.showDelay)).toBe(300) }) - test("Escape hides the tooltip", () => { - story( - update, - given(hoveredOpen), - message(Message.PressedEscape()), - Command.expectNone(), - model((next) => expect(next.isOpen).toBe(false)), - ) + test("a message for another trigger that does not activate it leaves the host alone", () => { + const open = run(null, "a", Message.FocusedTrigger()) + expect(run(open, "b", Message.LeftTrigger())).toBe(open) }) - test("leaving the trigger closes the tooltip even if it still has keyboard focus", () => { - story( - update, - given({ ...hoveredOpen, isFocused: true }), - message(Message.UnhoveredTrigger()), - Command.resolve(WaitForTooltipHideDelay, Message.CompletedWaitForHideDelay({ version: 2 })), - model((next) => expect(next).toMatchObject({ isOpen: false, isFocused: false })), - ) + test("a mouse press moves the host, so the focus that follows keeps the tooltip shut", () => { + const pressed = run(null, "a", Message.PressedPointerOnTrigger({ pointerType: "mouse" })) + expect(run(pressed, "a", Message.FocusedTrigger())?.isOpen).toBe(false) }) +}) - test("mount completions leave the tooltip as it was", () => { - story( - update, - given(hoveredOpen), - message(Message.CompletedTrackTrigger()), - message(Message.CompletedPortalTooltip()), - model((next) => expect(next).toEqual(hoveredOpen)), - ) +describe("toAnchorPlacement", () => { + test("maps React Aria placements to Floating UI ones, reading logical sides left to right", () => { + expect(toAnchorPlacement("top")).toBe("top") + expect(toAnchorPlacement("bottom start")).toBe("bottom-start") + expect(toAnchorPlacement("top right")).toBe("top-end") + expect(toAnchorPlacement("end top")).toBe("right-start") + expect(toAnchorPlacement("start bottom")).toBe("left-end") }) }) diff --git a/apps/web-foldkit/src/ui/tooltip.ts b/apps/web-foldkit/src/ui/tooltip.ts index 24c61e356..dc153cbb4 100644 --- a/apps/web-foldkit/src/ui/tooltip.ts +++ b/apps/web-foldkit/src/ui/tooltip.ts @@ -1,8 +1,8 @@ -import { Duration, Effect, Queue, Schema, Stream } from "effect" -import { Command, Mount, Submodel, type Update } from "foldkit" -import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" +import { anchorSetup, AnchorConfig } from "@foldkit/ui/anchor" +import * as FoldkitTooltip from "@foldkit/ui/tooltip" +import { Duration, Effect, Schema } from "effect" +import { Mount, Submodel } from "foldkit" +import { type ChildAttribute, type Html, type HtmlBuilder } from "foldkit/html" import { twJoin } from "tailwind-merge" import { tooltipArrowBase, @@ -10,233 +10,51 @@ import { tooltipArrowInverse, tooltipStyles, } from "~/components/ui/tooltip.styles" -import { currentGlobalModality, trackGlobalModality } from "./aria/interaction" -import { portalOverlay, positionOverlay } from "./aria/overlay" -import { Placement } from "./aria/placement" +import { type Placement, toAnchorPlacement } from "./aria/placement" -/** Port of `components/ui/tooltip.tsx` (React Aria TooltipTrigger + Tooltip). */ +/** + * The `@foldkit/ui` Tooltip, styled. Hover shows it after the show delay, keyboard focus at once, + * and leaving, blurring or Escape on the trigger hides it; the handlers live in the trigger bundle. + * The panel is positioned by Anchor with an arrow, so it renders its own panel attributes. + */ // MODEL +/** React Aria TooltipTrigger's default `delay`. */ const DEFAULT_SHOW_DELAY_MS = 1500 -export const Model = Schema.Struct({ - id: Schema.String, - isOpen: Schema.Boolean, - isHovered: Schema.Boolean, - isFocused: Schema.Boolean, - /** Bumped on every show or hide request, so a stale delay never acts. */ - version: Schema.Number, - /** TooltipTrigger `delay` (React Aria default 1500ms). */ - delayMs: Schema.Number, -}) -export type Model = typeof Model.Type - -export const init = (id: string): Model => initWithDelay(id, DEFAULT_SHOW_DELAY_MS) - -/** A trigger with its own `delay`. */ -export const initWithDelay = (id: string, delayMs: number): Model => ({ - id, - isOpen: false, - isHovered: false, - isFocused: false, - version: 0, - delayMs, -}) - -// MESSAGE - -export const Message = defineMessageUnion({ - HoveredTrigger: { isPointerModality: Schema.Boolean }, - UnhoveredTrigger: {}, - FocusedTrigger: { isFocusVisible: Schema.Boolean }, - BlurredTrigger: {}, - PressedTrigger: {}, - PressedEscape: {}, - CompletedWaitForShowDelay: { version: Schema.Number }, - CompletedWaitForHideDelay: { version: Schema.Number }, - CompletedTrackTrigger: {}, - CompletedPortalTooltip: {}, -}) -export type Message = typeof Message.Type - -export const triggerId = (id: string) => `${id}-trigger` -export const tooltipId = (id: string) => `${id}-tooltip` - -// COMMAND - -/** useTooltipTriggerState defaults. */ -const HIDE_DELAY = Duration.millis(500) - -export const WaitForTooltipShowDelay = Command.define("WaitForTooltipShowDelay", { - args: { version: Schema.Number, delayMs: Schema.Number }, - messages: [Message.CompletedWaitForShowDelay], - execute: ({ version, delayMs }) => - Effect.sleep(Duration.millis(delayMs)).pipe( - Effect.as(Message.CompletedWaitForShowDelay({ version })), - ), -}) - -export const WaitForTooltipHideDelay = Command.define("WaitForTooltipHideDelay", { - args: { version: Schema.Number }, - messages: [Message.CompletedWaitForHideDelay], - execute: ({ version }) => - Effect.sleep(HIDE_DELAY).pipe(Effect.as(Message.CompletedWaitForHideDelay({ version }))), -}) - -// UPDATE - -type UpdateReturn = Update.Return<Model, Message> +export const { Model, Message, OutMessage, update, triggerId, WaitBeforeShowing } = FoldkitTooltip +export type Model = FoldkitTooltip.Model +export type Message = FoldkitTooltip.Message +export type OutMessage = FoldkitTooltip.OutMessage -const requestShow = (model: Model, isFocus: boolean): UpdateReturn => { - const version = model.version + 1 - const next = modifyFields(model, { version: () => version }) - if (model.isOpen || isFocus) return { model: modifyFields(next, { isOpen: () => true }) } - return { model: next, commands: [WaitForTooltipShowDelay({ version, delayMs: model.delayMs })] } -} - -const requestHide = (model: Model, isImmediate: boolean): UpdateReturn => { - if (model.isHovered || model.isFocused) return { model } - const version = model.version + 1 - const next = modifyFields(model, { version: () => version }) - if (isImmediate || !model.isOpen) return { model: modifyFields(next, { isOpen: () => false }) } - return { model: next, commands: [WaitForTooltipHideDelay({ version })] } -} - -const released = (model: Model): Model => - modifyFields(model, { isHovered: () => false, isFocused: () => false }) +export const init = (id: string, showDelayMs: number = DEFAULT_SHOW_DELAY_MS): Model => + FoldkitTooltip.init({ id, showDelay: Duration.millis(showDelayMs) }) -export const update = (model: Model, message: Message): UpdateReturn => - Message.match<UpdateReturn>(message, { - HoveredTrigger: ({ isPointerModality }) => - requestShow(modifyFields(model, { isHovered: () => isPointerModality }), false), - UnhoveredTrigger: () => requestHide(released(model), false), - FocusedTrigger: ({ isFocusVisible }) => - isFocusVisible ? requestShow(modifyFields(model, { isFocused: () => true }), true) : { model }, - BlurredTrigger: () => requestHide(released(model), true), - PressedTrigger: () => requestHide(released(model), true), - PressedEscape: () => ({ model: modifyFields(model, { isOpen: () => false }) }), - CompletedWaitForShowDelay: ({ version }) => - version === model.version && (model.isHovered || model.isFocused) - ? { model: modifyFields(model, { isOpen: () => true }) } - : { model }, - CompletedWaitForHideDelay: ({ version }) => - version === model.version ? { model: modifyFields(model, { isOpen: () => false }) } : { model }, - CompletedTrackTrigger: () => ({ model }), - CompletedPortalTooltip: () => ({ model }), - }) +/** The panel id the trigger bundle's `aria-describedby` names. */ +export const tooltipId = (id: string) => `${id}-panel` +const arrowId = (id: string) => `${id}-arrow` // MOUNT -type TrackTriggerMessage = Exclude< - Message, - { - _tag: - | "CompletedWaitForShowDelay" - | "CompletedWaitForHideDelay" - | "CompletedPortalTooltip" - | "PressedEscape" - } -> - -export const TrackTrigger = Mount.defineStream("TrackTooltipTrigger", { - messages: [ - Message.CompletedTrackTrigger, - Message.HoveredTrigger, - Message.UnhoveredTrigger, - Message.FocusedTrigger, - Message.BlurredTrigger, - Message.PressedTrigger, - ], - execute: ({ element }) => - Stream.callback<TrackTriggerMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - trackGlobalModality() - const emit = (message: TrackTriggerMessage) => Queue.offerUnsafe(queue, message) - const listeners: ReadonlyArray<readonly [string, (event: Event) => void]> = [ - [ - "pointerenter", - (event) => { - if (event instanceof PointerEvent && event.pointerType !== "touch") - emit( - Message.HoveredTrigger({ - isPointerModality: currentGlobalModality() === "pointer", - }), - ) - }, - ], - [ - "pointerleave", - (event) => { - if (event instanceof PointerEvent && event.pointerType !== "touch") - emit(Message.UnhoveredTrigger()) - }, - ], - [ - "focus", - () => - emit( - Message.FocusedTrigger({ - isFocusVisible: currentGlobalModality() !== "pointer", - }), - ), - ], - ["blur", () => emit(Message.BlurredTrigger())], - ["pointerdown", () => emit(Message.PressedTrigger())], - ["keydown", () => emit(Message.PressedTrigger())], - ] - for (const [type, listener] of listeners) element.addEventListener(type, listener) - emit(Message.CompletedTrackTrigger()) - return () => { - for (const [type, listener] of listeners) element.removeEventListener(type, listener) - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ), -}) - -type PortalTooltipMessage = Extract<Message, { _tag: "CompletedPortalTooltip" | "PressedEscape" }> - -export const PortalTooltip = Mount.defineStream("PortalTooltip", { - args: { id: Schema.String, placement: Placement, offset: Schema.Number }, - messages: [Message.CompletedPortalTooltip, Message.PressedEscape], - execute: ({ element, id, placement, offset }) => - Stream.callback<PortalTooltipMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const releasePortal = portalOverlay(element, { isModal: false }) - const tooltip = element.firstElementChild - const releasePosition = - tooltip instanceof HTMLElement - ? positionOverlay(tooltip, { - triggerId: triggerId(id), - placement, - offset, - arrowSelector: ":scope > .group", - }) - : () => undefined - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") Queue.offerUnsafe(queue, Message.PressedEscape()) - } - document.addEventListener("keydown", onKeyDown, true) - Queue.offerUnsafe(queue, Message.CompletedPortalTooltip()) - return () => { - document.removeEventListener("keydown", onKeyDown, true) - releasePosition() - releasePortal() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ), +/** The kit's `AnchorTooltip` plus the arrow, which Anchor offsets through `--arrow-x`/`--arrow-y`. */ +export const AnchorTooltipPanel = Mount.define("AnchorTooltipPanel", { + args: { buttonId: Schema.String, anchor: AnchorConfig, arrowId: Schema.String }, + messages: [Message.CompletedAnchorTooltip], + execute: ({ element, buttonId, anchor, arrowId }) => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => anchorSetup(element, { buttonId, anchor, arrowId, interceptTab: false })), + (cleanup) => Effect.sync(cleanup), + ) + return Message.CompletedAnchorTooltip() + }), }) // VIEW export type ViewInputs = Readonly<{ - /** Renders the trigger (TooltipTrigger child). Spread `attributes` on it and put `overlay` last. */ + /** Renders the trigger. Spread `attributes` on it and put `overlay` last. */ toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html content: ReadonlyArray<Html | string> placement?: Placement @@ -246,83 +64,76 @@ export type ViewInputs = Readonly<{ className?: string }> +const TOOLTIP_OFFSET = 10 +const VIEWPORT_PADDING = 8 + export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const triggerAttributes = childAttributes([ - h.Id(triggerId(model.id)), - h.OnMount(TrackTrigger()), - ...(model.isOpen ? [h.Attribute("aria-describedby", tooltipId(model.id))] : []), - ]) - // The keyed slot stays in the trigger while the overlay inside it is portaled to <body>, so a - // sibling overlay opening in the same patch (the emoji picker) inserts before a node that is there. - return viewInputs.toTrigger( - triggerAttributes, - model.isOpen - ? h.keyed("div")( - `tooltip-slot-${model.id}`, - [h.Style({ display: "contents" })], - [tooltipOverlay(model, viewInputs, h)], - ) - : h.empty, + const anchor: AnchorConfig = { + placement: toAnchorPlacement(viewInputs.placement ?? "top"), + gap: viewInputs.offset ?? TOOLTIP_OFFSET, + padding: VIEWPORT_PADDING, + } + return FoldkitTooltip.view( + model, + { + anchor, + // The keyed slot stays in the trigger while the panel inside it is portaled to <body>, so a + // sibling overlay opening in the same patch (the emoji picker) inserts before a node that is there. + toView: ({ trigger, isVisible }) => + viewInputs.toTrigger( + trigger, + isVisible + ? h.keyed("div")( + `tooltip-slot-${model.id}`, + [h.Style({ display: "contents" })], + [panel(model.id, anchor, viewInputs, h)], + ) + : h.empty, + ), + }, + h, ) }) -const TOOLTIP_OFFSET = 10 +const sideOf = (anchor: AnchorConfig) => { + const placement = anchor.placement ?? "top" + return placement.startsWith("top") + ? "top" + : placement.startsWith("bottom") + ? "bottom" + : placement.startsWith("left") + ? "left" + : "right" +} -const tooltipOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Html => { - const inverse = viewInputs.inverse - const arrow = viewInputs.arrow ?? true +const panel = (id: string, anchor: AnchorConfig, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Html => { + const inverse = viewInputs.inverse ?? false return h.div( [ - h.Attribute("data-overlay-container", "true"), - h.OnMount( - PortalTooltip({ - id: model.id, - placement: viewInputs.placement ?? "top", - offset: viewInputs.offset ?? TOOLTIP_OFFSET, - }), - ), + h.Id(tooltipId(id)), + h.Role("tooltip"), + h.Style({ position: "absolute", margin: "0", visibility: "hidden", pointerEvents: "none" }), + h.DataAttribute("open", ""), + h.Class(tooltipStyles({ inverse, side: sideOf(anchor), className: viewInputs.className })), + h.OnMount(AnchorTooltipPanel({ buttonId: triggerId(id), anchor, arrowId: arrowId(id) })), ], [ - h.div( - [ - h.Class( - tooltipStyles({ - isEntering: false, - isExiting: false, - inverse, - className: viewInputs.className, - }), - ), - h.Id(tooltipId(model.id)), - h.Role("tooltip"), - ], - [ - ...(arrow - ? [ - h.div( - [h.Class("group")], - [ - h.svg( - [ - h.Attribute("width", "12"), - h.Attribute("height", "12"), - h.Attribute("viewBox", "0 0 12 12"), - h.Class( - twJoin( - tooltipArrowBase, - inverse ? tooltipArrowInverse : tooltipArrowDefault, - ), - ), - ], - [h.path([h.Attribute("d", "M0 0 L6 6 L12 0")])], - ), - ], - ), - ] - : []), - ...viewInputs.content, - ], - ), + ...((viewInputs.arrow ?? true) + ? [ + h.svg( + [ + h.Id(arrowId(id)), + h.Attribute("width", "12"), + h.Attribute("height", "12"), + h.Attribute("viewBox", "0 0 12 12"), + h.AriaHidden(true), + h.Class(twJoin(tooltipArrowBase, inverse ? tooltipArrowInverse : tooltipArrowDefault)), + ], + [h.path([h.Attribute("d", "M0 0 L6 6 L12 0")])], + ), + ] + : []), + ...viewInputs.content, ], ) } From 94319da972161a6de95ef7116e98cb692ade89c7 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:05:47 +0200 Subject: [PATCH 48/52] refactor(web): Modal and Sheet on @foldkit/ui Dialog (native <dialog>) The native dialog isolates the page, locks scroll, traps focus and returns it; the hand-rolled portal/inert/scroll-lock/focus-scope wiring is gone. Modal re-exports the Dialog Model, Message and OutMessage (Opened/Closed), so folds add foldOutMessage. opened/closed are gone: open/close return Commands, initOpen builds the open Model of a modal without a stored Model. The dialog renders only while open, so its Mount takes and releases the resources whether or not the parent keeps the Model. Panels and backdrops animate in from @starting-style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/core/components/ui/dialog.styles.ts | 2 +- .../src/core/components/ui/modal.styles.ts | 27 +- .../src/core/components/ui/sheet.styles.ts | 26 +- apps/web-foldkit/src/overlay/modal/frame.ts | 26 +- .../channel-settings/connect/scene.test.ts | 4 +- .../connect/share-modal-view.ts | 1 - .../channel-settings/connect/share-modal.ts | 12 +- .../integrations/scene.test.ts | 2 +- .../integrations/story.test.ts | 2 +- .../channel-settings/integrations/update.ts | 1 + .../channel-settings/integrations/view.ts | 1 - .../my-settings/profile/profile.scene.test.ts | 6 +- .../chat-sync-connection/add-link-view.ts | 2 - .../chat-sync-connection/scene.test.ts | 12 +- .../settings/chat-sync-connection/update.ts | 6 +- .../page/settings/chat-sync/confirm-dialog.ts | 2 - .../src/page/settings/chat-sync/scene.test.ts | 8 +- .../src/page/settings/chat-sync/update.ts | 4 +- .../src/page/settings/custom-emojis/modals.ts | 2 - .../page/settings/custom-emojis/scene.test.ts | 4 +- .../page/settings/custom-emojis/story.test.ts | 2 +- .../src/page/settings/custom-emojis/update.ts | 1 + .../page/settings/general/delete-workspace.ts | 2 - .../settings/general/general.scene.test.ts | 4 +- .../src/page/settings/general/update.ts | 1 + apps/web-foldkit/src/shell/mobile.ts | 16 +- .../web-foldkit/src/shell/shell.story.test.ts | 2 +- apps/web-foldkit/src/shell/update.ts | 4 +- apps/web-foldkit/src/test/pages-fixtures.ts | 2 +- apps/web-foldkit/src/ui/dialog.ts | 20 +- apps/web-foldkit/src/ui/modal.scene.test.ts | 146 ++------ apps/web-foldkit/src/ui/modal.story.test.ts | 105 ++---- apps/web-foldkit/src/ui/modal.ts | 349 ++++++++---------- apps/web-foldkit/src/ui/sheet.scene.test.ts | 111 ++---- apps/web-foldkit/src/ui/sheet.ts | 143 ++----- 35 files changed, 368 insertions(+), 690 deletions(-) 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 efdc7ab35..5e4db28e5 100644 --- a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts @@ -10,7 +10,7 @@ export const dialogDescriptionBase = "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/modal.styles.ts b/apps/web-foldkit/src/core/components/ui/modal.styles.ts index 5b1d6b65b..8b9d9d42f 100644 --- a/apps/web-foldkit/src/core/components/ui/modal.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/modal.styles.ts @@ -16,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", - "data-entering:fade-in data-entering:animate-in data-entering:duration-300 data-entering:ease-out", - "data-exiting:fade-out data-exiting:animate-out data-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", - "data-entering:slide-in-from-bottom sm:data-entering:zoom-in-95 sm:data-entering:slide-in-from-bottom-0 data-entering:animate-in data-entering:duration-300 data-entering:ease-out", - "data-exiting:slide-out-to-bottom sm:data-exiting:zoom-out-95 sm:data-exiting:slide-out-to-bottom-0 data-exiting:animate-out data-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/sheet.styles.ts b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts index d8e991cac..77fc02a81 100644 --- a/apps/web-foldkit/src/core/components/ui/sheet.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts @@ -17,23 +17,18 @@ const generateCompoundVariants = (sides: Array<Sides>) => { })) } +/** 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: "data-entering:slide-in-from-top data-exiting:slide-out-to-top inset-x-0 top-0 rounded-b-2xl border-b", - bottom: "data-entering:slide-in-from-bottom data-exiting:slide-out-to-bottom inset-x-0 bottom-0 rounded-t-2xl border-t", - left: "data-entering:slide-in-from-left data-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: "data-entering:slide-in-from-right data-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", @@ -43,12 +38,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/overlay/modal/frame.ts b/apps/web-foldkit/src/overlay/modal/frame.ts index c917a2746..b04024263 100644 --- a/apps/web-foldkit/src/overlay/modal/frame.ts +++ b/apps/web-foldkit/src/overlay/modal/frame.ts @@ -20,25 +20,11 @@ type FrameMessage = KitModal.Message export const initFrame = (id: string): Frame => ({ id }) -/** The frame's Dismiss, close icon, Escape or outside press. */ -export const isCloseRequest = (message: FrameMessage): boolean => - KitModal.Message.match<boolean>(message, { - ClickedTrigger: () => false, - ClickedClose: () => true, - PressedEscape: () => true, - PressedOutside: () => true, - CompletedPortalModal: () => false, - }) +/** The frame's close icon, a close button, Escape or a press on the backdrop. */ +export const isCloseRequest = KitModal.isCloseRequest -/** The frame has portaled and moved focus into its dialog: the moment for a field's `autoFocus`. */ -export const isPortalReady = (message: FrameMessage): boolean => - KitModal.Message.match<boolean>(message, { - ClickedTrigger: () => false, - ClickedClose: () => false, - PressedEscape: () => false, - PressedOutside: () => false, - CompletedPortalModal: () => true, - }) +/** The frame's dialog rendered and moved focus in: the moment for a field's `autoFocus`. */ +export const isPortalReady = KitModal.isReady export interface FrameOptions { readonly size?: ModalSize @@ -57,9 +43,9 @@ export const frameView = <Message>( ): Html => h.submodel({ slotId: frame.id, - model: KitModal.opened(KitModal.init(frame.id)), + model: KitModal.initOpen(frame.id), view: KitModal.view, - viewInputs: { toTrigger: (_attributes, overlay) => overlay, toContent, ...options }, + viewInputs: { toContent, ...options }, toParentMessage, }) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts b/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts index 59e005d58..32260965a 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/scene.test.ts @@ -75,7 +75,7 @@ describe("share channel", () => { Scene.Command.resolve(ShareModal.CreateInvite, ShareModal.Message.SucceededCreateInvite()), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Invite sent") })), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Command.resolve( ListOutgoingInvites, Message.SucceededListOutgoingInvites({ organizationId, invites: [pendingInvite] }), @@ -105,7 +105,7 @@ describe("share channel", () => { Scene.expect(Scene.text("No public workspaces found")).toExist(), Scene.click(Scene.within(dialog, Scene.role("button", { name: "Cancel" }))), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) }) diff --git a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts index b443424b1..1206b12a2 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/share-modal-view.ts @@ -86,7 +86,6 @@ const body = (h: HtmlBuilder<Message>, model: Model, channelName: string): Html model: model.modal, view: Modal.view, viewInputs: { - toTrigger: (_attributes, overlay) => overlay, size: "lg", toContent: () => [ dialogHeader(h, {}, [ diff --git a/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts b/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts index 51b1fb2ec..9d78aa76b 100644 --- a/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts +++ b/apps/web-foldkit/src/page/channel-settings/connect/share-modal.ts @@ -134,11 +134,9 @@ export const CreateInvite = Command.define("CreateInvite", { export type Return = Update.ReturnWithOutMessage<Model, Message, PageOutMessage, HazelRpc> export type Context = Readonly<{ channelId: ChannelId; organizationId: OrganizationId | null }> -/** `resetState()` plus closing; `onOpenChange(false)` resets as well. */ -const closed = (model: Model): Model => ({ ...init(), modal: Modal.close(model.modal).model }) - +/** The modal renders open, and its dialog Mount takes the focus trap, isolation and scroll lock. */ export const open = (model: Model): Model => - modifyFields(model, { modal: (modal) => Modal.open(modal).model }) + modifyFields(model, { modal: (modal) => Modal.initOpen(modal.id) }) export const update = (model: Model, message: Message, context: Context): Return => Message.match<Return>(message, { @@ -187,7 +185,7 @@ export const update = (model: Model, message: Message, context: Context): Return ToggledAllowGuestMemberAdds: ({ isSelected }) => ({ model: modifyFields(model, { allowGuestMemberAdds: () => isSelected }), }), - ClickedCancel: () => ({ model: closed(model) }), + ClickedCancel: () => ({ model: init() }), ClickedSendInvite: () => { const selected = model.selectedWorkspace if (selected === null || !selected.slug || model.isSubmitting) return { model } @@ -204,7 +202,7 @@ export const update = (model: Model, message: Message, context: Context): Return } }, SucceededCreateInvite: () => ({ - model: closed(model), + model: init(), outMessage: PageOutMessage.RequestedToast({ toast: successToast("Invite sent") }), }), FailedCreateInvite: ({ toast }) => ({ @@ -215,7 +213,7 @@ export const update = (model: Model, message: Message, context: Context): Return const next = Modal.update(model.modal, modalMessage) const nextModel = next.model.isOpen ? modifyFields(model, { modal: () => next.model }) - : closed(model) + : init() return { model: nextModel, commands: Command.mapMessages(next.commands ?? [], (child) => diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts index 5afccd6ce..6d32da694 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/scene.test.ts @@ -249,7 +249,7 @@ describe("row removal", () => { ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Webhook deleted") })), Scene.expect(confirmDialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.Command.resolve(ListWebhooks, Message.SucceededListWebhooks({ version: 2, webhooks: [] })), Scene.expect(Scene.text("No webhooks yet")).toExist(), diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts index 2c75b222e..91b8b5137 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/story.test.ts @@ -235,7 +235,7 @@ describe("row removal", () => { const pending = run(confirming, Message.ClickedConfirmRemove()).model const escaped = run( pending, - Message.GotConfirmModalMessage({ message: Modal.Message.PressedEscape() }), + Message.GotConfirmModalMessage({ message: Modal.Message.RequestedClose() }), ).model const settled = run( escaped, diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/update.ts b/apps/web-foldkit/src/page/channel-settings/integrations/update.ts index 807c3e553..acf6eb3c0 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/update.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/update.ts @@ -70,6 +70,7 @@ const confirmModal = { !modal.isOpen && confirm._tag === "Confirming" ? { _tag: "Closed" as const } : confirm, }), toParentMessage: (message: Modal.Message) => Message.GotConfirmModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldConfirmModal = Update.foldChild({ update: Modal.update, ...confirmModal }) const openConfirmModal = Update.foldChildStep({ update: Modal.open, ...confirmModal }) diff --git a/apps/web-foldkit/src/page/channel-settings/integrations/view.ts b/apps/web-foldkit/src/page/channel-settings/integrations/view.ts index 4032c44ed..a01c225ab 100644 --- a/apps/web-foldkit/src/page/channel-settings/integrations/view.ts +++ b/apps/web-foldkit/src/page/channel-settings/integrations/view.ts @@ -43,7 +43,6 @@ const confirmModal = (h: HtmlBuilder<Message>, model: Model): Html => { model: model.confirmModal, view: Modal.view, viewInputs: { - toTrigger: (_attributes, overlay) => overlay, role: "alertdialog", size: "xs", toContent: (closeAttributes) => [ diff --git a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts index 9ab01ec16..c8db424c5 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts @@ -90,7 +90,7 @@ const dialog = Scene.role("dialog") const cropModalMounted = Scene.Mount.resolve( // Name-only matcher: the Mount is wrapped by mapMessage, so its typed result is the parent Message. { name: "PortalModal" }, - Message.GotCropModalMessage({ message: Modal.Message.CompletedPortalModal() }), + Message.GotCropModalMessage({ message: Modal.Message.SucceededAcquireResources() }), ) const toastOf = (intent: "success" | "error", title: string, description: string | null = null) => PageOutMessage.RequestedToast({ toast: { intent, title, description } }) @@ -172,7 +172,7 @@ describe("avatar crop dialog scene", () => { Scene.expect(Scene.within(dialog, Scene.role("button", { name: "Saving..." }))).toBeDisabled(), Scene.Command.resolve(CropAvatarImage, Message.CompletedCropImage({ blob: new Blob(["webp"]) })), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.expect(Scene.text("Uploading...")).toExist(), Scene.expect(Scene.role("button", { name: "Remove photo" })).toBeDisabled(), Scene.Command.resolveAll( @@ -195,7 +195,7 @@ describe("avatar crop dialog scene", () => { ...openCrop, Scene.click(Scene.within(dialog, Scene.role("button", { name: "Cancel" }))), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Command.expectExact(RevokeCropImage({ src: "blob:me" })), Scene.Command.resolve(RevokeCropImage, Message.CompletedRevokeCropImage()), ) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts index 3a50b4834..c5c915031 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/add-link-view.ts @@ -274,6 +274,4 @@ export const addLinkModal = (h: HtmlBuilder<Message>, model: Model): Html => toParentMessage: toAddLinkModalMessage, toContent: (closeAttributes) => content(h, model, closeAttributes), size: "lg", - autoFocusId: CHANNEL_SEARCH_ID, - restoresFocusToPrevious: true, }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts index eb3abe40a..258d1df21 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts @@ -59,7 +59,7 @@ const linkMenuMounted = Scene.Mount.resolve(FocusTriggerOnPress, Menu.Message.Co /** The add link modal is a controlled modal: its Mount result arrives already wrapped. */ const addLinkModalMounted = Scene.Mount.resolve( { name: "PortalModal" }, - Message.GotAddLinkModalMessage({ message: Modal.Message.CompletedPortalModal() }), + Message.GotAddLinkModalMessage({ message: Modal.Message.SucceededAcquireResources() }), ) describe("connection states", () => { @@ -117,7 +117,7 @@ describe("disconnect", () => { }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) @@ -138,7 +138,7 @@ describe("disconnect", () => { }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.expect(Scene.role("button", { name: "Disconnect" })).toBeEnabled(), ) }) @@ -147,7 +147,7 @@ describe("disconnect", () => { describe("remove a channel link", () => { const confirming = step( { ...withLink, deleteTarget: { id: linkId, name: "general" } }, - Message.GotDeleteLinkModalMessage({ message: Modal.Message.ClickedTrigger() }), + Message.GotDeleteLinkModalMessage({ message: Modal.Message.RequestedOpen() }), ) test("confirming removes the link, toasts and reloads the links", () => { @@ -164,7 +164,7 @@ describe("remove a channel link", () => { PageOutMessage.RequestedToast({ toast: successToast("Channel link removed") }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.expect(Scene.text("Loading channel links...")).toExist(), Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.Command.resolve( @@ -230,7 +230,7 @@ describe("link a channel", () => { ), Scene.expectOutMessage(PageOutMessage.RequestedToast({ toast: successToast("Channel linked") })), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Command.resolve( ListChannelLinks, Message.SucceededListChannelLinks({ version: 2, links: [link] }), diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts index 9be716fdb..625d85705 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/update.ts @@ -22,6 +22,7 @@ import { } from "./command" import { ADD_LINK_MODAL_ID, + CHANNEL_SEARCH_ID, type ChannelLink, linkMenuId, Message, @@ -58,7 +59,7 @@ export const init = (route: RouteOf<"SettingsChatSyncConnection">, shared: Share updatingLinkIds: [], channelNames: {}, linkMenus: [], - addLinkModal: Modal.init(ADD_LINK_MODAL_ID), + addLinkModal: Modal.init(ADD_LINK_MODAL_ID, { focusSelector: `#${CHANNEL_SEARCH_ID}` }), discordChannels: { _tag: "Loading" }, hazelChannels: [], selectedChannel: null, @@ -131,6 +132,7 @@ const deleteLinkModal = { deleteTarget: (target) => (modal.isOpen ? target : null), }), toParentMessage: (message: Modal.Message) => Message.GotDeleteLinkModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldDeleteLinkModal = Update.foldChild({ update: Modal.update, ...deleteLinkModal }) const openDeleteLinkModal = Update.foldChildStep({ update: Modal.open, ...deleteLinkModal }) @@ -140,6 +142,7 @@ const disconnectModal = { read: (model: Model) => Option.some(model.disconnectModal), write: (model: Model, modal: Modal.Model): Model => modifyFields(model, { disconnectModal: () => modal }), toParentMessage: (message: Modal.Message) => Message.GotDisconnectModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldDisconnectModal = Update.foldChild({ update: Modal.update, ...disconnectModal }) const openDisconnectModal = Update.foldChildStep({ update: Modal.open, ...disconnectModal }) @@ -206,6 +209,7 @@ const addLinkModal = { focusedSearch: () => null, }), toParentMessage: (message: Modal.Message) => Message.GotAddLinkModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } /** `onOpenChange={(open) => !open && handleClose()}`; the portal Mount focuses the channel search (`autoFocus`). */ const foldAddLinkModal = Update.foldChild({ update: Modal.update, ...addLinkModal }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts b/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts index c41e8da08..42720832b 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/confirm-dialog.ts @@ -31,8 +31,6 @@ export const confirmDialog = <Message>( model: options.modal, view: Modal.view, viewInputs: { - // Controlled `isOpen` with no DialogTrigger. - toTrigger: (_attributes, overlay) => overlay, size: "md", toContent: (closeAttributes) => [ dialog(h, { id: `${options.slotId}-inner`, role: "dialog", labelledBy: titleId }, [ diff --git a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts index 24715d5e6..62723b972 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts @@ -41,7 +41,7 @@ const addTriggerMounted = Scene.Mount.resolve( ) const addModalMounted = Scene.Mount.resolve( { name: "PortalModal" }, - Message.GotAddModalMessage({ message: Modal.Message.CompletedPortalModal() }), + Message.GotAddModalMessage({ message: Modal.Message.SucceededAcquireResources() }), ) describe("list states", () => { @@ -108,7 +108,7 @@ describe("delete a connection", () => { PageOutMessage.RequestedToast({ toast: successToast("Connection deleted") }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.expect(Scene.text("Loading connections...")).toExist(), Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.Command.resolve( @@ -156,7 +156,7 @@ describe("delete a connection", () => { Scene.click(Scene.within(dialog, Scene.role("button", { name: "Cancel" }))), Scene.Command.expectNone(), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) }) @@ -191,7 +191,7 @@ describe("connect a Discord server", () => { PageOutMessage.RequestedToast({ toast: successToast("Discord connection created") }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Mount.expectEnded(FocusTriggerOnPress), Scene.Command.resolve( ListConnections, diff --git a/apps/web-foldkit/src/page/settings/chat-sync/update.ts b/apps/web-foldkit/src/page/settings/chat-sync/update.ts index accc5b855..ff0b71b43 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/update.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/update.ts @@ -127,6 +127,7 @@ const addModal = { isGuildSearchFocused: () => false, }), toParentMessage: (message: Modal.Message) => Message.GotAddModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldAddModalChild = Update.foldChild({ update: Modal.update, ...addModal }) const openAddModal = Update.foldChildStep({ update: Modal.open, ...addModal }) @@ -136,7 +137,7 @@ const closeAddModal = Update.foldChildStep({ update: Modal.close, ...addModal }) const foldAddModal = (model: Model, message: Modal.Message): Return => { const result = foldAddModalChild(model, message) const focus = - result.model.addModal.isOpen && message._tag === "CompletedPortalModal" && hasGuildSearch(model) + result.model.addModal.isOpen && Modal.isReady(message) && hasGuildSearch(model) ? [FocusGuildSearch()] : [] return { model: result.model, commands: [...(result.commands ?? []), ...focus] } @@ -198,6 +199,7 @@ const deleteModal = { deleteTarget: (target) => (modal.isOpen ? target : null), }), toParentMessage: (message: Modal.Message) => Message.GotDeleteModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldDeleteModal = Update.foldChild({ update: Modal.update, ...deleteModal }) const openDeleteModal = Update.foldChildStep({ update: Modal.open, ...deleteModal }) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts b/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts index 44fc50249..5b92f279f 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/modals.ts @@ -40,7 +40,6 @@ export const deleteEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html => model: model.deleteModal, view: Modal.view, viewInputs: { - toTrigger: (_attributes, overlay) => overlay, toContent: (closeAttributes) => [ innerDialog(h, id, [ dialogHeader(h, {}, [ @@ -86,7 +85,6 @@ export const restoreEmojiModal = (h: HtmlBuilder<Message>, model: Model): Html = model: model.restoreModal, view: Modal.view, viewInputs: { - toTrigger: (_attributes, overlay) => overlay, toContent: (closeAttributes) => [ innerDialog(h, id, [ dialogHeader(h, {}, [ diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts b/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts index 68d7d8716..3e589df9d 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/scene.test.ts @@ -129,7 +129,7 @@ describe("delete", () => { Scene.click(Scene.role("button", { name: "Delete emoji" })), Scene.Command.expectExact(DeleteCustomEmoji({ emojiId: shipit.id, name: "shipit" })), Scene.expect(deleteDialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), Scene.Command.resolve(DeleteCustomEmoji, Message.SucceededDeleteEmoji({ name: "shipit" })), Scene.expectOutMessage( PageOutMessage.RequestedToast({ toast: successToast("Emoji :shipit: deleted") }), @@ -146,7 +146,7 @@ describe("delete", () => { Scene.click(Scene.within(deleteDialog, Scene.role("button", { name: "Cancel" }))), Scene.Command.expectNone(), Scene.expect(deleteDialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) }) diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts b/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts index fc2e2a9b9..68a8135c1 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/story.test.ts @@ -348,7 +348,7 @@ describe("restore a deleted emoji", () => { story( owner, given({ ...drafted, restoreTarget, restoreModal: Modal.open(drafted.restoreModal).model }), - message(Message.GotRestoreModalMessage({ message: Modal.Message.ClickedClose() })), + message(Message.GotRestoreModalMessage({ message: Modal.Message.RequestedClose() })), model((current) => expect(current.restoreTarget).toBeNull()), message(Message.ClickedConfirmRestore()), Command.expectNone(), diff --git a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts index 73f61dbf5..e25d4b27b 100644 --- a/apps/web-foldkit/src/page/settings/custom-emojis/update.ts +++ b/apps/web-foldkit/src/page/settings/custom-emojis/update.ts @@ -205,6 +205,7 @@ const modalFold = ( ...(modal.isOpen ? {} : { [target]: null }), }), toParentMessage, + foldOutMessage: Modal.ignoreOutMessage, } } const deleteModal = modalFold("deleteModal", (message) => Message.GotDeleteModalMessage({ message })) diff --git a/apps/web-foldkit/src/page/settings/general/delete-workspace.ts b/apps/web-foldkit/src/page/settings/general/delete-workspace.ts index 0ff5e5c08..7e62cd767 100644 --- a/apps/web-foldkit/src/page/settings/general/delete-workspace.ts +++ b/apps/web-foldkit/src/page/settings/general/delete-workspace.ts @@ -19,8 +19,6 @@ export const deleteWorkspaceModal = (h: HtmlBuilder<Message>, model: Model, orga model: model.deleteModal, view: Modal.view, viewInputs: { - // Controlled: no trigger, only the overlay while open. - toTrigger: (_attributes, overlay) => overlay, toContent: () => [ dialogHeader(h, {}, [ h.div( diff --git a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts index 3fccbb006..3d4353bae 100644 --- a/apps/web-foldkit/src/page/settings/general/general.scene.test.ts +++ b/apps/web-foldkit/src/page/settings/general/general.scene.test.ts @@ -119,7 +119,7 @@ describe("delete workspace", () => { Scene.expect(confirmDelete).toBeEnabled(), Scene.click(Scene.within(dialog, Scene.role("button", { name: "Cancel" }))), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) @@ -142,7 +142,7 @@ describe("delete workspace", () => { }), ), Scene.expect(dialog).toBeAbsent(), - Scene.Mount.expectEnded(Modal.PortalModal), + Scene.Mount.expectEnded(Modal.AcquireResources), ) }) diff --git a/apps/web-foldkit/src/page/settings/general/update.ts b/apps/web-foldkit/src/page/settings/general/update.ts index a238f5dce..e3faf0a65 100644 --- a/apps/web-foldkit/src/page/settings/general/update.ts +++ b/apps/web-foldkit/src/page/settings/general/update.ts @@ -202,6 +202,7 @@ const deleteModalFold = { confirmationText: (text) => (deleteModal.isOpen ? text : ""), }), toParentMessage: (message: Modal.Message) => Message.GotDeleteModalMessage({ message }), + foldOutMessage: Modal.ignoreOutMessage, } const foldDeleteModal = Update.foldChild({ update: Modal.update, ...deleteModalFold }) const openDeleteModal = Update.foldChildStep({ update: Modal.open, ...deleteModalFold }) diff --git a/apps/web-foldkit/src/shell/mobile.ts b/apps/web-foldkit/src/shell/mobile.ts index 2aea00ffe..7a339d678 100644 --- a/apps/web-foldkit/src/shell/mobile.ts +++ b/apps/web-foldkit/src/shell/mobile.ts @@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge" import { IconBell, IconDashboard, IconGear, IconMenu, IconMsgs } from "../icons" import { AppRoute, hrefOf } from "../route" import { button } from "../ui/button" -import type * as Modal from "../ui/modal" +import * as Modal from "../ui/modal" import * as Sheet from "../ui/sheet" import { isActiveFuzzy, type ShellContext } from "./context" @@ -113,6 +113,10 @@ export const mobileMenuButton = <Message>( export const mobileSidebarPlaceholder = <Message>(h: HtmlBuilder<Message>): Html => h.span([h.Attribute("aria-hidden", "true"), h.Class("sr-only"), h.Attribute("data-intent", "default")]) +/** The sheet's Model, derived from the shell's `isSidebarOpen`: open for as long as the flag is set. */ +export const mobileSidebarModel = (isOpen: boolean): Modal.Model => + isOpen ? Modal.initOpen(MOBILE_SIDEBAR_ID) : Modal.init(MOBILE_SIDEBAR_ID) + /** `<Sidebar>` on mobile: a controlled left `SheetContent` labelled "Sidebar", without a close button. */ export const mobileSidebarSheet = <Message>( h: HtmlBuilder<Message>, @@ -124,19 +128,15 @@ export const mobileSidebarSheet = <Message>( ): Html => h.submodel({ slotId: MOBILE_SIDEBAR_ID, - model: { id: MOBILE_SIDEBAR_ID, isOpen: options.isOpen }, + model: mobileSidebarModel(options.isOpen), view: Sheet.view, viewInputs: { - // Controlled from the header and bottom nav, so the overlay renders without a trigger. - toTrigger: (_attributes, overlay) => overlay, toContent: () => options.children, side: "left", closeButton: false, ariaLabel: "Sidebar", - restoresFocusToPrevious: true, - overlayAttributes: { "data-slot": "sidebar", "data-intent": "default" }, - className: - "w-(--sidebar-width) data-entering:blur-in data-exiting:blur-out [--sidebar-width:18rem] has-data-[slot=calendar]:[--sidebar-width:23rem]", + panelAttributes: { "data-slot": "sidebar", "data-intent": "default" }, + className: "w-(--sidebar-width) [--sidebar-width:18rem] has-data-[slot=calendar]:[--sidebar-width:23rem]", }, toParentMessage: options.toMessage, }) diff --git a/apps/web-foldkit/src/shell/shell.story.test.ts b/apps/web-foldkit/src/shell/shell.story.test.ts index 12a439c10..78bf944ec 100644 --- a/apps/web-foldkit/src/shell/shell.story.test.ts +++ b/apps/web-foldkit/src/shell/shell.story.test.ts @@ -152,7 +152,7 @@ describe("mobile sidebar sheet", () => { given(ready), message(Message.ToggledSidebar({ isOpen: true })), model((m) => expect(m.isSidebarOpen).toBe(true)), - message(Message.GotMobileSidebarMessage({ message: Modal.Message.PressedEscape() })), + message(Message.GotMobileSidebarMessage({ message: Modal.Message.RequestedClose() })), model((m) => expect(m.isSidebarOpen).toBe(false)), ) }) diff --git a/apps/web-foldkit/src/shell/update.ts b/apps/web-foldkit/src/shell/update.ts index a8acb8887..56a001dbc 100644 --- a/apps/web-foldkit/src/shell/update.ts +++ b/apps/web-foldkit/src/shell/update.ts @@ -9,7 +9,7 @@ import * as Menu from "../ui/menu" import * as Modal from "../ui/modal" import * as ChannelsSidebar from "./channels-sidebar" import { ORG_SWITCHER_ID, orgSwitcherEntries, USER_MENU_ID, userMenuEntries } from "./menus" -import { MOBILE_SIDEBAR_ID } from "./mobile" +import { mobileSidebarModel } from "./mobile" import * as Notifications from "./notifications" import { Message, type Model } from "./model" @@ -213,7 +213,7 @@ export const update = (model: Model, message: Message, context: Context): ShellU ToggledSidebar: ({ isOpen }) => ({ model: modifyFields(model, { isSidebarOpen: () => isOpen }) }), ChangedViewport: ({ isMobile }) => ({ model: modifyFields(model, { isMobile: () => isMobile }) }), GotMobileSidebarMessage: ({ message }) => { - const sheet = Modal.update({ id: MOBILE_SIDEBAR_ID, isOpen: model.isSidebarOpen }, message) + const sheet = Modal.update(mobileSidebarModel(model.isSidebarOpen), message) return { model: modifyFields(model, { isSidebarOpen: () => sheet.model.isOpen }), commands: Command.mapMessages(sheet.commands ?? [], (child) => diff --git a/apps/web-foldkit/src/test/pages-fixtures.ts b/apps/web-foldkit/src/test/pages-fixtures.ts index 236456cc2..6c07590fc 100644 --- a/apps/web-foldkit/src/test/pages-fixtures.ts +++ b/apps/web-foldkit/src/test/pages-fixtures.ts @@ -63,7 +63,7 @@ export const pageScene = <Model, Message>( }) /** Acknowledges the overlay Mount every open `ui/modal` dialog renders. */ -export const portalModalMounted = Scene.Mount.resolve(Modal.PortalModal, Modal.Message.CompletedPortalModal()) +export const portalModalMounted = Scene.Mount.resolve(Modal.AcquireResources, Modal.Message.SucceededAcquireResources()) /** A server failure toast, as a Command's `failureToast` would build it. */ export const failureToastFixture: ToastRequest = { diff --git a/apps/web-foldkit/src/ui/dialog.ts b/apps/web-foldkit/src/ui/dialog.ts index 616e1c662..f5db7eb56 100644 --- a/apps/web-foldkit/src/ui/dialog.ts +++ b/apps/web-foldkit/src/ui/dialog.ts @@ -13,9 +13,8 @@ import { IconClose } from "../icons" import { button } from "./button" /** - * Port of `components/ui/dialog.tsx`: the parts Modal, Sheet and Popover compose. The overlay that - * hosts a dialog owns its behavior; these are pure views. Header and footer heights - * (`--dialog-header-height`, `--dialog-footer-height`) are measured by that host's Mount. + * The dialog parts Modal, Sheet and Popover compose. The overlay that hosts a dialog owns its + * behavior; these are pure views. The body takes the height the header and footer leave (flex). */ type Children = ReadonlyArray<Html | string> @@ -46,6 +45,17 @@ export const dialog = <Message>( children, ) +/** The content wrapper inside a Modal or Sheet panel; the `<dialog>` around it carries the role. */ +export const dialogSection = <Message>( + h: HtmlBuilder<Message>, + options: Readonly<{ id: string; className?: string }>, + children: Children, +): Html => + h.section( + [h.Class(twMerge(dialogBase, options.className)), h.Attribute("data-slot", "dialog"), h.Id(options.id)], + children, + ) + /** `DialogTitle` (React Aria Heading): level 2 with an id inside a Dialog, level 3 without one in a Popover. */ export const dialogTitle = <Message>( h: HtmlBuilder<Message>, @@ -124,10 +134,8 @@ export const dialogCloseIcon = <Message>( ...attributes, h.Attribute("aria-label", "Close"), h.Class(twMerge(twMerge(dialogCloseIconBase), className)), - h.Attribute("data-react-aria-pressable", "true"), h.Attribute("slot", "close"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), + h.Type("button"), ], [IconClose(h, { className: "size-4" })], ) diff --git a/apps/web-foldkit/src/ui/modal.scene.test.ts b/apps/web-foldkit/src/ui/modal.scene.test.ts index 2be43dfc7..ea5ecd15a 100644 --- a/apps/web-foldkit/src/ui/modal.scene.test.ts +++ b/apps/web-foldkit/src/ui/modal.scene.test.ts @@ -3,14 +3,11 @@ import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" import { dialogClose, dialogFooter, dialogHeader } from "./dialog" -import type { HtmlBuilder } from "foldkit/html" -import { controlledModal, init, Message, type Model, PortalModal, titleId, update, view } from "./modal" +import { AcquireResources, CloseDialog, init, initOpen, Message, titleId, update, view } from "./modal" -/** Modal through the view: trigger state, the labelled dialog, close button, Escape and outside press. */ +/** Modal through the view: a native dialog labelled by its title, closed by its buttons and backdrop. */ -// The overlay renders beside the trigger: scene clicks bubble through the vdom, not the portal. const sceneView = Scene.withViewInputs(view, { - toTrigger: (attributes, overlay) => ih.div([], [ih.button([...attributes], ["Open rename"]), overlay]), toContent: (closeAttributes) => [ dialogHeader(ih, { title: { id: titleId("rename"), text: "Rename thread" }, @@ -20,146 +17,67 @@ const sceneView = Scene.withViewInputs(view, { ], }) -const config = { update, view: sceneView() } -const trigger = Scene.role("button", { name: "Open rename" }) -// The Escape handler sits on the overlay root, which has no role. -const overlayRoot = Scene.selector('[style="display: contents;"]') +type Inputs = Parameters<typeof sceneView>[0] +// A closed modal renders nothing, so the scene view wraps it in an element. +const wrapped = + (inputs?: Inputs) => + (...args: Parameters<ReturnType<typeof sceneView>>) => + ih.main([], [sceneView(inputs)(...args)]) +const config = { update, view: wrapped() } const dialog = Scene.role("dialog", { name: "Rename thread" }) +const backdrop = Scene.selector('[data-slot="modal-overlay"]') const opened = [ - Scene.given(init("rename")), - Scene.click(trigger), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), + Scene.given(initOpen("rename")), + Scene.Mount.resolve(AcquireResources, Message.SucceededAcquireResources()), +] +const closedByRequest = () => [ + Scene.Command.resolve(CloseDialog, Message.CompletedCloseDialog()), + Scene.Mount.expectEnded(AcquireResources), + Scene.expect(Scene.role("dialog")).toBeAbsent(), ] describe("modal scene", () => { - test("the trigger is collapsed and no dialog renders while the modal is closed", () => { + test("a closed modal renders nothing", () => { Scene.scene( config, Scene.given(init("rename")), - Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), - Scene.expect(trigger).not.toHaveAttr("aria-controls"), Scene.expect(Scene.role("dialog")).toBeAbsent(), Scene.Mount.expectNone(), ) }) - test("pressing the trigger opens a dialog labelled by its title", () => { + test("an open modal is a dialog labelled by its title that takes its resources on mount", () => { Scene.scene( config, - ...opened, - Scene.expect(trigger).toHaveAttr("aria-expanded", "true"), - Scene.expect(trigger).toHaveAttr("aria-controls", "rename-dialog"), - Scene.expect(trigger).toHaveAttr("data-pressed", "true"), - Scene.expect(dialog).toHaveAttr("aria-labelledby", "rename-title"), - Scene.expect(dialog).toHaveId("rename-dialog"), + Scene.given(initOpen("rename")), + Scene.Mount.expectExact( + AcquireResources({ id: "rename", focusSelector: "[data-foldkit-dialog-initial-focus]" }), + ), + Scene.Mount.resolve(AcquireResources, Message.SucceededAcquireResources()), + Scene.expect(dialog).toHaveAttr("aria-modal", "true"), Scene.expect(Scene.role("heading", { name: "Rename thread" })).toExist(), Scene.expect(Scene.text("Pick a name.")).toExist(), ) }) - test("the close icon button closes the modal", () => { - Scene.scene( - config, - ...opened, - Scene.click(Scene.role("button", { name: "Close" })), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), - ) + test("the close icon closes the modal", () => { + Scene.scene(config, ...opened, Scene.click(Scene.role("button", { name: "Close" })), ...closedByRequest()) }) test("a close button in the content closes the modal", () => { - Scene.scene( - config, - ...opened, - Scene.click(Scene.role("button", { name: "Cancel" })), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - ) - }) - - test("Escape inside the modal closes it", () => { - Scene.scene( - config, - ...opened, - Scene.keydown(overlayRoot, "Escape"), - Scene.expectHandled(), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - ) + Scene.scene(config, ...opened, Scene.click(Scene.role("button", { name: "Cancel" })), ...closedByRequest()) }) - test("other keys inside the modal fall through", () => { - Scene.scene( - config, - ...opened, - Scene.keydown(overlayRoot, "a"), - Scene.expectIgnored(), - Scene.expect(dialog).toExist(), - ) + test("a press on the backdrop dismisses the modal", () => { + Scene.scene(config, ...opened, Scene.click(backdrop), ...closedByRequest()) }) - test("a press outside the modal dismisses it", () => { + test("an alert dialog has no close icon and its backdrop does not dismiss", () => { Scene.scene( - config, + { update, view: wrapped({ role: "alertdialog" }) }, ...opened, - Scene.Subscription.emit(Message.PressedOutside()), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - ) - }) - - test("an alert dialog has no close icon and no hidden dismiss button", () => { - Scene.scene( - { update, view: sceneView({ role: "alertdialog" }) }, - Scene.given(init("rename")), - Scene.click(trigger), - Scene.Mount.expectHas( - PortalModal({ - id: "rename", - isDismissable: false, - restoresToPrevious: false, - autoFocusId: null, - }), - ), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), Scene.expect(Scene.role("alertdialog", { name: "Rename thread" })).toExist(), Scene.expect(Scene.role("button", { name: "Close" })).toBeAbsent(), - Scene.expect(Scene.role("button", { name: "Dismiss" })).toBeAbsent(), - ) - }) - - test("a modal its parent opens without a trigger restores focus to the previous element and closes on Escape", () => { - const controlledView = (model: Model, h: HtmlBuilder<Message>) => - h.main( - [], - [ - controlledModal(h, { - model, - toParentMessage: (message) => message, - toContent: () => [ - dialogHeader(h, { title: { id: titleId("rename"), text: "Rename thread" } }), - ], - restoresFocusToPrevious: true, - }), - ], - ) - Scene.scene( - { update, view: controlledView }, - Scene.given({ ...init("rename"), isOpen: true }), - Scene.Mount.expectExact( - PortalModal({ - id: "rename", - isDismissable: true, - restoresToPrevious: true, - autoFocusId: null, - }), - ), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), - Scene.expect(dialog).toExist(), - Scene.keydown(overlayRoot, "Escape"), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), ) }) }) diff --git a/apps/web-foldkit/src/ui/modal.story.test.ts b/apps/web-foldkit/src/ui/modal.story.test.ts index 02ef5bb7d..83faf90d1 100644 --- a/apps/web-foldkit/src/ui/modal.story.test.ts +++ b/apps/web-foldkit/src/ui/modal.story.test.ts @@ -1,89 +1,60 @@ // @vitest-environment jsdom -import { Command, given, message, model, story } from "foldkit/story" +import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { close, closed, init, Message, open, opened, update } from "./modal" - -/** React Aria DialogTrigger + Modal: the trigger opens, close, Escape and outside presses dismiss. */ - -const closedModal = init("rename") -const openModal = { ...closedModal, isOpen: true } +import { + CloseDialog, + close, + init, + initOpen, + isCloseRequest, + isReady, + Message, + OutMessage, + open, + ShowDialog, + update, +} from "./modal" + +/** Modal on @foldkit/ui Dialog: opening shows the native dialog, closing closes it, both announced. */ describe("modal story", () => { - test("a new modal starts closed", () => { - story( - update, - given(closedModal), - model((next) => expect(next).toEqual({ id: "rename", isOpen: false })), - ) + test("a new modal starts closed; initOpen is the open Model", () => { + expect(init("rename").isOpen).toBe(false) + expect(initOpen("rename").isOpen).toBe(true) }) - test("pressing the trigger opens the modal and runs no Commands", () => { + test("a request to open shows the dialog and emits Opened", () => { story( update, - given(closedModal), - message(Message.ClickedTrigger()), - Command.expectNone(), + given(init("rename")), + message(Message.RequestedOpen()), + expectOutMessage(OutMessage.Opened()), + Command.resolve(ShowDialog, Message.SucceededShowDialog()), model((next) => expect(next.isOpen).toBe(true)), ) }) - test("pressing the trigger again keeps an open modal open", () => { + test("a request to close closes the dialog and emits Closed", () => { story( update, - given(openModal), - message(Message.ClickedTrigger()), - model((next) => expect(next.isOpen).toBe(true)), - ) - }) - - test("the close button closes the modal", () => { - story( - update, - given(openModal), - message(Message.ClickedClose()), + given(initOpen("rename")), + message(Message.RequestedClose()), + expectOutMessage(OutMessage.Closed()), + Command.resolve(CloseDialog, Message.CompletedCloseDialog()), model((next) => expect(next.isOpen).toBe(false)), ) }) - test("Escape closes the modal", () => { - story( - update, - given(openModal), - message(Message.PressedEscape()), - model((next) => expect(next.isOpen).toBe(false)), - ) + test("open and close from the parent match the Messages", () => { + expect(open(init("rename")).model.isOpen).toBe(true) + expect(close(initOpen("rename")).model.isOpen).toBe(false) + expect(close(initOpen("rename")).outMessage).toEqual(OutMessage.Closed()) }) - test("a press outside the modal closes it", () => { - story( - update, - given(openModal), - message(Message.PressedOutside()), - Command.expectNone(), - model((next) => expect(next.isOpen).toBe(false)), - ) - }) - - test("the portal finishing its mount leaves the modal as it was", () => { - story( - update, - given(openModal), - message(Message.CompletedPortalModal()), - model((next) => expect(next).toEqual(openModal)), - ) - }) - - test("a parent can open and close the modal without a Message", () => { - expect(open(closedModal).model.isOpen).toBe(true) - expect(close(openModal).model.isOpen).toBe(false) - expect(open(closedModal).commands).toBeUndefined() - }) -}) - -describe("modal parent helpers", () => { - test("opened and closed return the Model that open and close carry, without Commands to drop", () => { - expect(opened(closedModal)).toEqual(open(closedModal).model) - expect(open(closedModal).commands ?? []).toEqual([]) - expect(closed(opened(closedModal))).toEqual(close(opened(closedModal)).model) + test("isCloseRequest and isReady name the Messages parents react to", () => { + expect(isCloseRequest(Message.RequestedClose())).toBe(true) + expect(isCloseRequest(Message.SucceededAcquireResources())).toBe(false) + expect(isReady(Message.SucceededAcquireResources())).toBe(true) + expect(isReady(Message.RequestedClose())).toBe(false) }) }) diff --git a/apps/web-foldkit/src/ui/modal.ts b/apps/web-foldkit/src/ui/modal.ts index 628222162..7557dd63d 100644 --- a/apps/web-foldkit/src/ui/modal.ts +++ b/apps/web-foldkit/src/ui/modal.ts @@ -1,226 +1,181 @@ -import { Effect, Option, Queue, Schema, Stream } from "effect" -import { Mount, Submodel, type Update } from "foldkit" -import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" +import { Dialog } from "@foldkit/ui" +import { Submodel } from "foldkit" +import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { twMerge } from "tailwind-merge" -import { type ModalSize, modalContentBase, modalOverlayClassName } from "~/components/ui/modal.styles" import { - containFocus, - dismissButton, - observeDialogParts, - portalOverlay, - restoreFocusTo, - restoreFocusToPrevious, - trackViewportHeight, - watchInteractOutside, -} from "./aria/overlay" -import { dialog, dialogCloseIcon } from "./dialog" - -/** Port of `components/ui/modal.tsx` (React Aria DialogTrigger + ModalOverlay + Modal + Dialog). */ + type ModalSize, + modalBackdropClassName, + modalContentBase, + modalLayoutClassName, +} from "~/components/ui/modal.styles" +import { dialogCloseIcon, dialogSection } from "./dialog" + +/** + * A modal dialog on @foldkit/ui Dialog: a native `<dialog>` that isolates the page, locks scroll, + * traps focus, closes on Escape and returns focus when it closes. The panel fades and scales in + * through `@starting-style`, so modals that exist only while open (the app's modal host) animate too. + * Sheet shares this Model, Message and update; only its panel differs. + */ // MODEL -export const Model = Schema.Struct({ id: Schema.String, isOpen: Schema.Boolean }) -export type Model = typeof Model.Type +export const Model = Dialog.Model +export type Model = Dialog.Model -export const init = (id: string): Model => ({ id, isOpen: false }) +export type InitConfig = Readonly<{ + /** A selector for the element focused on open, in place of the first focusable one. */ + focusSelector?: string +}> + +export const init = (id: string, config: InitConfig = {}): Model => Dialog.init({ id, ...config }) + +/** + * The open Model of a modal whose parent keeps no Model (a modal open for as long as it renders, or + * one derived from a parent's flag). Its Mount takes the dialog resources; removal releases them. + */ +export const initOpen = (id: string): Model => ({ ...init(id), isOpen: true }) // MESSAGE -export const Message = defineMessageUnion({ - ClickedTrigger: {}, - ClickedClose: {}, - PressedEscape: {}, - PressedOutside: {}, - CompletedPortalModal: {}, -}) -export type Message = typeof Message.Type +export const Message = Dialog.Message +export type Message = Dialog.Message + +export const OutMessage = Dialog.OutMessage +export type OutMessage = Dialog.OutMessage + +/** The Mount on the `<dialog>` that acquires its resources once it renders open. */ +export const AcquireResources = Dialog.AcquireResources +export const ShowDialog = Dialog.ShowDialog +export const CloseDialog = Dialog.CloseDialog -export const triggerId = (id: string) => `${id}-trigger` -export const dialogId = (id: string) => `${id}-dialog` -export const titleId = (id: string) => `${id}-title` +export const titleId = (id: string): string => Dialog.titleId(init(id)) +export const dialogId = (id: string): string => `${id}-content` + +/** The modal asked to close: its close button, close icon, Escape or a press on the backdrop. */ +export const isCloseRequest = (message: Message): boolean => Message.guards.RequestedClose(message) + +/** The dialog rendered open and moved focus in: the moment for a field's autofocus. */ +export const isReady = (message: Message): boolean => + Message.guards.SucceededAcquireResources(message) // UPDATE -/** The open Model, for parents that set `isOpen` without folding Commands (prefer it to `open(m).model`). */ -export const opened = (model: Model): Model => modifyFields(model, { isOpen: () => true }) -/** The closed Model; `close(m).model` callers should use this, since `.model` drops Commands. */ -export const closed = (model: Model): Model => modifyFields(model, { isOpen: () => false }) - -export const update = (model: Model, message: Message): Update.Return<Model, Message> => - Message.match<Update.Return<Model, Message>>(message, { - ClickedTrigger: () => ({ model: opened(model) }), - ClickedClose: () => ({ model: closed(model) }), - PressedEscape: () => ({ model: closed(model) }), - PressedOutside: () => ({ model: closed(model) }), - CompletedPortalModal: () => ({ model }), - }) +export const update = Dialog.update +/** Opens the modal from the parent; fold it with `Update.foldChildStep`. */ +export const open = Dialog.open +/** Closes the modal from the parent; fold it with `Update.foldChildStep`. */ +export const close = Dialog.close -/** Opens the modal from the parent (controlled `isOpen`). */ -export const open = (model: Model): Update.Return<Model, Message> => ({ model: opened(model) }) -/** Closes the modal from the parent. */ -export const close = (model: Model): Update.Return<Model, Message> => ({ model: closed(model) }) - -// MOUNT - -type PortalModalMessage = Extract<Message, { _tag: "CompletedPortalModal" | "PressedOutside" }> - -/** The Overlay + ModalOverlay + FocusScope behavior shared by Modal and Sheet. */ -export const PortalModal = Mount.defineStream("PortalModal", { - /** `restoresToPrevious`: a controlled overlay with no trigger returns focus to what had it before. */ - /** `autoFocusId`: an `autoFocus` element, focused on mount in place of the dialog (useDialog skips it then). */ - args: { - id: Schema.String, - isDismissable: Schema.Boolean, - restoresToPrevious: Schema.Boolean, - autoFocusId: Schema.NullOr(Schema.String), - }, - messages: [Message.CompletedPortalModal, Message.PressedOutside], - execute: ({ element, id, isDismissable, restoresToPrevious, autoFocusId }) => - Stream.callback<PortalModalMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const restoreFocus = restoresToPrevious - ? restoreFocusToPrevious(element) - : restoreFocusTo(triggerId(id), element) - const releasePortal = portalOverlay(element, { isModal: true, inertAfterPaint: true }) - const overlay = element.querySelector<HTMLElement>("[data-modal-overlay]") - const releaseViewport = overlay ? trackViewportHeight(overlay) : () => undefined - const releaseParts = observeDialogParts(element) - const autoFocus = autoFocusId === null ? null : document.getElementById(autoFocusId) - const initialFocus = autoFocus ?? document.getElementById(dialogId(id)) - initialFocus?.focus({ preventScroll: true }) - const releaseFocus = containFocus(element) - const releaseOutside = isDismissable - ? watchInteractOutside(`[data-modal-content="${CSS.escape(id)}"]`, () => - Queue.offerUnsafe(queue, Message.PressedOutside()), - ) - : () => undefined - Queue.offerUnsafe(queue, Message.CompletedPortalModal()) - return () => { - releaseOutside() - releaseFocus() - releaseParts() - releaseViewport() - releasePortal() - restoreFocus() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ), -}) +/** A `foldOutMessage` for a parent that reads `isOpen` rather than reacting to Opened or Closed. */ +export const ignoreOutMessage = + (_outMessage: OutMessage) => + <ParentModel>(model: ParentModel) => ({ model }) // VIEW -export type ViewInputs = Readonly<{ - /** Renders the trigger (DialogTrigger child). Spread `attributes` on it and put `overlay` last. */ - toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html +export type Role = "dialog" | "alertdialog" + +/** What Modal and Sheet share: the role, dismissal and the content. */ +export type DialogInputs = Readonly<{ /** Dialog content; spread `closeAttributes` on ModalClose buttons. */ toContent: (closeAttributes: ReadonlyArray<ChildAttribute>) => ReadonlyArray<Html> - size?: ModalSize - role?: "dialog" | "alertdialog" + role?: Role + /** Defaults to true for a dialog and false for an alertdialog. */ isDismissable?: boolean isBlurred?: boolean closeButton?: boolean className?: string - /** The id of the content's `autoFocus` element. */ - autoFocusId?: string - /** A controlled Modal opened from an element outside DialogTrigger: FocusScope restores focus to it. */ - restoresFocusToPrevious?: boolean + /** `aria-label` on the dialog; without it the dialog is labelled by its title (`titleId`). */ + ariaLabel?: string }> -export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const triggerAttributes = childAttributes([ - ...(model.isOpen ? [h.Attribute("aria-controls", dialogId(model.id))] : []), - h.Attribute("aria-expanded", model.isOpen ? "true" : "false"), - ...(model.isOpen ? [h.Attribute("data-pressed", "true")] : []), - h.Id(triggerId(model.id)), - h.OnClick(Message.ClickedTrigger()), - ]) - return viewInputs.toTrigger( - triggerAttributes, - model.isOpen ? modalOverlay(model, viewInputs, h, (message) => message) : h.empty, - ) -}) +export type ViewInputs = DialogInputs & Readonly<{ size?: ModalSize }> -/** - * A controlled Modal (`<Modal isOpen>` with no DialogTrigger) rendered in the parent's view, so the - * content can carry the parent's Messages; the Modal's own Messages go through `toParentMessage`. - */ -export const controlledModal = <ParentMessage>( - h: HtmlBuilder<ParentMessage>, - options: Omit<ViewInputs, "toTrigger"> & { - readonly model: Model - readonly toParentMessage: (message: Message) => ParentMessage - }, -): Html => (options.model.isOpen ? modalOverlay(options.model, options, h, options.toParentMessage) : h.empty) +type Parts = Readonly<{ + backdropClassName: string + layoutClassName: string | null + panelClassName: string + closeIconClassName?: string + panelAttributes?: ReadonlyArray<ChildAttribute> +}> -const modalOverlay = <ParentMessage>( +/** The `<dialog>` Modal and Sheet render: a backdrop, then the panel above it (not inside it). */ +export const dialogView = <M>( + h: HtmlBuilder<M>, model: Model, - viewInputs: Omit<ViewInputs, "toTrigger">, + inputs: DialogInputs, + parts: Parts, + toParentMessage: (message: Message) => M, +): Html => + h.submodel({ + slotId: model.id, + model, + view: Dialog.view, + viewInputs: { + toView: (render: Dialog.RenderInfo) => { + const role = inputs.role ?? "dialog" + const isDismissable = inputs.isDismissable ?? role !== "alertdialog" + const panel = h.div( + [ + ...render.panel, + h.Class(twMerge(parts.panelClassName, inputs.className)), + h.DataAttribute("slot", "modal-content"), + ...(parts.panelAttributes ?? []), + ], + [ + dialogSection(h, { id: dialogId(model.id) }, [ + ...inputs.toContent(render.closeButton), + ...((inputs.closeButton ?? true) && isDismissable + ? [dialogCloseIcon(h, render.closeButton, parts.closeIconClassName)] + : []), + ]), + ], + ) + // Rendered only while visible, so every open inserts a fresh `<dialog>` whose Mount takes + // the dialog resources, and every close removes it and releases them, whether or not the + // parent keeps the Model. + if (!render.isVisible) return h.empty + return h.dialog( + [ + ...render.dialog, + ...(role === "alertdialog" ? [h.Role("alertdialog")] : []), + ...(inputs.ariaLabel === undefined ? [] : [h.AriaLabel(inputs.ariaLabel)]), + h.Class("m-0 overflow-visible bg-transparent p-0"), + ], + [ + h.div([ + ...(isDismissable ? render.backdrop : []), + h.Class( + twMerge(parts.backdropClassName, (inputs.isBlurred ?? false) && "backdrop-blur-[1px]"), + ), + h.DataAttribute("slot", "modal-overlay"), + ]), + parts.layoutClassName === null ? panel : h.div([h.Class(parts.layoutClassName)], [panel]), + ], + ) + }, + }, + toParentMessage, + }) + +const modalParts = (size: ModalSize): Parts => ({ + backdropClassName: modalBackdropClassName, + layoutClassName: modalLayoutClassName(size), + panelClassName: twMerge(...modalContentBase(size)), +}) + +/** The modal as a Submodel view: `h.submodel({ model, view: Modal.view, viewInputs, toParentMessage })`. */ +export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => + dialogView(h, model, viewInputs, modalParts(viewInputs.size ?? "lg"), (message) => message), +) + +/** The modal rendered straight from the parent's view, its Messages wrapped by `toParentMessage`. */ +export const controlledModal = <ParentMessage>( h: HtmlBuilder<ParentMessage>, - send: (message: Message) => ParentMessage, -): Html => { - const size = viewInputs.size ?? "lg" - const role = viewInputs.role ?? "dialog" - const isDismissable = viewInputs.isDismissable ?? role !== "alertdialog" - const closeAttributes = childAttributes([h.OnClick(send(Message.ClickedClose()))]) - return h.div( - [ - h.Attribute("style", "display: contents;"), - h.OnMount( - Mount.mapMessage( - PortalModal({ - id: model.id, - isDismissable, - restoresToPrevious: viewInputs.restoresFocusToPrevious ?? false, - autoFocusId: viewInputs.autoFocusId ?? null, - }), - send, - ), - ), - h.OnKeyDownPreventDefault((key) => - key === "Escape" ? Option.some(send(Message.PressedEscape())) : Option.none(), - ), - ], - [ - h.span([ - h.Attribute("data-focus-scope-start", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - h.div( - [ - h.Class(modalOverlayClassName(size, viewInputs.isBlurred ?? false)), - h.Attribute("data-modal-overlay", ""), - h.Attribute("data-slot", "modal-overlay"), - ], - [ - h.div( - [ - h.Class(twMerge(twMerge(...modalContentBase(size)), viewInputs.className)), - h.Attribute("data-modal-content", model.id), - h.Attribute("data-slot", "modal-content"), - ], - [ - ...(isDismissable ? [dismissButton(h, send(Message.ClickedClose()))] : []), - dialog(h, { id: dialogId(model.id), role, labelledBy: titleId(model.id) }, [ - ...viewInputs.toContent(closeAttributes), - ...((viewInputs.closeButton ?? true) && isDismissable - ? [dialogCloseIcon(h, closeAttributes)] - : []), - ]), - ], - ), - ], - ), - h.span([ - h.Attribute("data-focus-scope-end", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - ], - ) -} + options: ViewInputs & + Readonly<{ + model: Model + toParentMessage: (message: Message) => ParentMessage + }>, +): Html => + dialogView(h, options.model, options, modalParts(options.size ?? "lg"), options.toParentMessage) diff --git a/apps/web-foldkit/src/ui/sheet.scene.test.ts b/apps/web-foldkit/src/ui/sheet.scene.test.ts index 740159a0a..dee2a7ac7 100644 --- a/apps/web-foldkit/src/ui/sheet.scene.test.ts +++ b/apps/web-foldkit/src/ui/sheet.scene.test.ts @@ -3,127 +3,60 @@ import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" import { dialogClose, dialogFooter, dialogHeader } from "./dialog" -import { init, Message, PortalModal, titleId, update } from "./modal" +import { AcquireResources, CloseDialog, initOpen, Message, titleId, update } from "./modal" import { view } from "./sheet" -/** Sheet through the view: a Modal with an edge panel, sharing the Modal update and PortalModal. */ +/** Sheet through the view: a Modal with an edge panel, sharing the Modal Model and update. */ -// The overlay renders beside the trigger: scene clicks bubble through the vdom, not the portal. const sceneView = Scene.withViewInputs(view, { - toTrigger: (attributes, overlay) => ih.div([], [ih.button([...attributes], ["Open details"]), overlay]), toContent: (closeAttributes) => [ dialogHeader(ih, { title: { id: titleId("details"), text: "Channel details" } }), dialogFooter(ih, [dialogClose(ih, closeAttributes, ["Done"])]), ], }) -const config = { update, view: sceneView() } -const trigger = Scene.role("button", { name: "Open details" }) +type Inputs = Parameters<typeof sceneView>[0] +// A closed sheet renders nothing, so the scene view wraps it in an element. +const wrapped = + (inputs?: Inputs) => + (...args: Parameters<ReturnType<typeof sceneView>>) => + ih.main([], [sceneView(inputs)(...args)]) +const config = { update, view: wrapped() } const sheet = Scene.role("dialog", { name: "Channel details" }) -// The Escape handler sits on the overlay root, which has no role. -const overlayRoot = Scene.selector('[style="display: contents;"]') const opened = [ - Scene.given(init("details")), - Scene.click(trigger), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), + Scene.given(initOpen("details")), + Scene.Mount.resolve(AcquireResources, Message.SucceededAcquireResources()), ] describe("sheet scene", () => { - test("pressing the trigger opens a sheet labelled by its title", () => { - Scene.scene( - config, - Scene.given(init("details")), - Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), - Scene.click(trigger), - Scene.Mount.expectExact( - PortalModal({ - id: "details", - isDismissable: true, - restoresToPrevious: false, - autoFocusId: null, - }), - ), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), - Scene.expect(trigger).toHaveAttr("aria-expanded", "true"), - Scene.expect(trigger).toHaveAttr("aria-controls", "details-dialog"), - Scene.expect(sheet).toHaveAttr("aria-labelledby", "details-title"), - ) + test("an open sheet is a dialog labelled by its title", () => { + Scene.scene(config, ...opened, Scene.expect(sheet).toHaveAttr("aria-modal", "true")) }) - test("the close icon closes the sheet", () => { + test("an aria-label names a sheet without a title", () => { Scene.scene( - config, + { update, view: wrapped({ ariaLabel: "Sidebar", toContent: () => [] }) }, ...opened, - Scene.click(Scene.role("button", { name: "Close" })), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), + Scene.expect(Scene.role("dialog", { name: "Sidebar" })).toExist(), ) }) - test("a close button in the content closes the sheet", () => { + test("Done closes the sheet", () => { Scene.scene( config, ...opened, Scene.click(Scene.role("button", { name: "Done" })), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - ) - }) - - test("Escape closes the sheet", () => { - Scene.scene( - config, - ...opened, - Scene.keydown(overlayRoot, "Escape"), - Scene.expectHandled(), - Scene.Mount.expectEnded(PortalModal), - Scene.expect(Scene.role("dialog")).toBeAbsent(), - ) - }) - - test("a press outside the sheet dismisses it", () => { - Scene.scene( - config, - ...opened, - Scene.Subscription.emit(Message.PressedOutside()), - Scene.Mount.expectEnded(PortalModal), + Scene.Command.resolve(CloseDialog, Message.CompletedCloseDialog()), + Scene.Mount.expectEnded(AcquireResources), Scene.expect(Scene.role("dialog")).toBeAbsent(), ) }) - test("an aria-label replaces the title as the sheet's name", () => { - Scene.scene( - { update, view: sceneView({ ariaLabel: "Navigation" }) }, - ...opened, - Scene.expect(Scene.role("dialog", { name: "Navigation" })).not.toHaveAttr("aria-labelledby"), - ) - }) - - test("overlay attributes land on the modal overlay", () => { + test("panel attributes land on the panel", () => { Scene.scene( - { update, view: sceneView({ overlayAttributes: { "data-slot": "sheet-overlay" } }) }, + { update, view: wrapped({ panelAttributes: { "data-intent": "default" } }) }, ...opened, - Scene.expect(Scene.selector("[data-modal-overlay]")).toHaveAttr("data-slot", "sheet-overlay"), - ) - }) - - test("a non-dismissable alert sheet has no close icon and ignores outside presses", () => { - Scene.scene( - { update, view: sceneView({ role: "alertdialog" }) }, - Scene.given(init("details")), - Scene.click(trigger), - Scene.Mount.expectExact( - PortalModal({ - id: "details", - isDismissable: false, - restoresToPrevious: false, - autoFocusId: null, - }), - ), - Scene.Mount.resolve(PortalModal, Message.CompletedPortalModal()), - Scene.expect(Scene.role("alertdialog", { name: "Channel details" })).toExist(), - Scene.expect(Scene.role("button", { name: "Close" })).toBeAbsent(), + Scene.expect(Scene.selector('[data-intent="default"]')).toExist(), ) }) }) diff --git a/apps/web-foldkit/src/ui/sheet.ts b/apps/web-foldkit/src/ui/sheet.ts index 0a9b023dd..2b460f721 100644 --- a/apps/web-foldkit/src/ui/sheet.ts +++ b/apps/web-foldkit/src/ui/sheet.ts @@ -1,125 +1,42 @@ -import { Option } from "effect" import { Submodel } from "foldkit" -import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" import { type Sides, + sheetBackdropClassName, sheetCloseIconClassName, sheetContentStyles, - sheetOverlayClassName, } from "~/components/ui/sheet.styles" -import { dismissButton } from "./aria/overlay" -import { dialog, dialogCloseIcon } from "./dialog" -import { dialogId, Message, type Model, PortalModal, titleId, triggerId } from "./modal" +import { type DialogInputs, dialogView, type Message, type Model } from "./modal" /** - * Port of `components/ui/sheet.tsx`. A sheet is a Modal with edge-anchored panel classes, so it - * shares the Modal Model, Message and update (`Modal.init`, `Modal.update`); only the view differs. + * A Modal with an edge-anchored panel: it shares the Modal Model, Message and update + * (`Modal.init`, `Modal.update`); only the view differs. */ -export type ViewInputs = Readonly<{ - /** Renders the trigger (DialogTrigger child). Spread `attributes` on it and put `overlay` last. */ - toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html - /** Sheet content; spread `closeAttributes` on SheetClose buttons. */ - toContent: (closeAttributes: ReadonlyArray<ChildAttribute>) => ReadonlyArray<Html> - side?: Sides - isFloat?: boolean - role?: "dialog" | "alertdialog" - isDismissable?: boolean - isBlurred?: boolean - closeButton?: boolean - className?: string - /** `aria-label` on the dialog; without it the dialog is labelled by its title. */ - ariaLabel?: string - /** Extra attributes ModalOverlay spreads on the overlay (`data-slot`, `data-intent`). */ - overlayAttributes?: Readonly<Record<string, string>> - /** Controlled sheets opened from elsewhere: focus returns to the element that had it on open. */ - restoresFocusToPrevious?: boolean -}> +export type ViewInputs = DialogInputs & + Readonly<{ + side?: Sides + isFloat?: boolean + /** Extra attributes on the panel (`data-slot`, `data-intent`). */ + panelAttributes?: Readonly<Record<string, string>> + }> -export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const triggerAttributes = childAttributes([ - ...(model.isOpen ? [h.Attribute("aria-controls", dialogId(model.id))] : []), - h.Attribute("aria-expanded", model.isOpen ? "true" : "false"), - ...(model.isOpen ? [h.Attribute("data-pressed", "true")] : []), - h.Id(triggerId(model.id)), - h.OnClick(Message.ClickedTrigger()), - ]) - return viewInputs.toTrigger( - triggerAttributes, - model.isOpen ? sheetOverlay(model, viewInputs, h) : h.empty, - ) -}) - -const sheetOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Html => { - const role = viewInputs.role ?? "dialog" - const isDismissable = viewInputs.isDismissable ?? role !== "alertdialog" - const closeAttributes = childAttributes([h.OnClick(Message.ClickedClose())]) - return h.div( - [ - h.Attribute("style", "display: contents;"), - h.OnMount( - PortalModal({ - id: model.id, - isDismissable, - restoresToPrevious: viewInputs.restoresFocusToPrevious ?? false, - autoFocusId: null, - }), - ), - h.OnKeyDownPreventDefault((key) => - key === "Escape" ? Option.some(Message.PressedEscape()) : Option.none(), - ), - ], - [ - h.span([ - h.Attribute("data-focus-scope-start", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - h.div( - [ - h.Class(sheetOverlayClassName(false, false, viewInputs.isBlurred ?? false)), - h.Attribute("data-modal-overlay", ""), - ...Object.entries(viewInputs.overlayAttributes ?? {}).map(([name, value]) => - h.Attribute(name, value), - ), - ], - [ - h.div( - [ - h.Class( - sheetContentStyles({ - isEntering: false, - isExiting: false, - side: viewInputs.side ?? "right", - isFloat: viewInputs.isFloat ?? true, - className: viewInputs.className, - }), - ), - h.Attribute("data-modal-content", model.id), - ], - [ - ...(isDismissable ? [dismissButton(h, Message.ClickedClose())] : []), - dialog( - h, - viewInputs.ariaLabel - ? { id: dialogId(model.id), role, ariaLabel: viewInputs.ariaLabel } - : { id: dialogId(model.id), role, labelledBy: titleId(model.id) }, - [ - ...viewInputs.toContent(closeAttributes), - ...((viewInputs.closeButton ?? true) && isDismissable - ? [dialogCloseIcon(h, closeAttributes, sheetCloseIconClassName)] - : []), - ], - ), - ], - ), - ], +export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => + dialogView( + h, + model, + viewInputs, + { + backdropClassName: sheetBackdropClassName, + layoutClassName: null, + panelClassName: sheetContentStyles({ + side: viewInputs.side ?? "right", + isFloat: viewInputs.isFloat ?? true, + }), + closeIconClassName: sheetCloseIconClassName, + panelAttributes: Object.entries(viewInputs.panelAttributes ?? {}).map(([name, value]) => + h.Attribute(name, value), ), - h.span([ - h.Attribute("data-focus-scope-end", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - ], - ) -} + }, + (message) => message, + ), +) From 7657bbb68f9489b407cf189c2c9e18682d626e57 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:08:15 +0200 Subject: [PATCH 49/52] refactor(web): Popover on @foldkit/ui Popover with Anchor positioning Floating UI places the panel; a press on the backdrop, Escape and the close buttons close it and focus returns to the trigger. The panel renders beside its trigger instead of inside the button, and focus goes to the content (contentFocus) so controls inside keep it open. The popover host's module-level closed-Model cache is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/chat/message/row.ts | 5 - apps/web-foldkit/src/chat/popover-host.ts | 15 +- .../src/core/components/ui/popover.styles.ts | 26 +- apps/web-foldkit/src/page/chat/header.ts | 3 +- apps/web-foldkit/src/page/chat/pinned.ts | 2 +- apps/web-foldkit/src/page/chat/row-context.ts | 4 +- apps/web-foldkit/src/ui/popover.scene.test.ts | 102 +++----- apps/web-foldkit/src/ui/popover.story.test.ts | 73 +----- apps/web-foldkit/src/ui/popover.ts | 246 +++++++----------- 9 files changed, 162 insertions(+), 314 deletions(-) diff --git a/apps/web-foldkit/src/chat/message/row.ts b/apps/web-foldkit/src/chat/message/row.ts index b63469eb0..e4d238b34 100644 --- a/apps/web-foldkit/src/chat/message/row.ts +++ b/apps/web-foldkit/src/chat/message/row.ts @@ -63,15 +63,11 @@ export const avatarButton = <M>( h: HtmlBuilder<M>, row: MessageRow, attributes: ReadonlyArray<ChildAttribute>, - overlay: Html, ) => h.button( [ ...attributes, h.Class("size-fit outline-hidden"), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), ], [ avatar(h, { @@ -80,7 +76,6 @@ export const avatarButton = <M>( src: row.author.avatarUrl, seed: row.author.displayName || undefined, }), - overlay, ], ) 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<string, Popover.Model>() -const closedModel = (key: string) => { - let model = closedModels.get(key) - if (model === undefined) closedModels.set(key, (model = Popover.init(key))) - return model -} - export interface PopoverTriggerOptions<M> { 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<ChildAttribute>, overlay: Html) => Html + readonly toTrigger: (attributes: ReadonlyArray<ChildAttribute>) => Html readonly toContent: () => ReadonlyArray<Html> readonly placement?: Placement readonly className?: string @@ -28,7 +21,7 @@ export const popoverTrigger = <M>(h: HtmlBuilder<M>, 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/core/components/ui/popover.styles.ts b/apps/web-foldkit/src/core/components/ui/popover.styles.ts index ce55e5955..4eb9dc5a2 100644 --- a/apps/web-foldkit/src/core/components/ui/popover.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/popover.styles.ts @@ -1,15 +1,27 @@ +/** + * 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)]", - "data-entering:fade-in data-exiting:fade-out data-entering:animate-in data-exiting:animate-out", - "data-[placement=left]:data-entering:slide-in-from-right-1 data-[placement=right]:data-entering:slide-in-from-left-1 data-[placement=top]:data-entering:slide-in-from-bottom-1 data-[placement=bottom]:data-entering:slide-in-from-top-1", - "data-[placement=left]:data-exiting:slide-out-to-right-1 data-[placement=right]:data-exiting:slide-out-to-left-1 data-[placement=top]:data-exiting:slide-out-to-bottom-1 data-[placement=bottom]:data-exiting:slide-out-to-top-1", + "group/popover z-50 min-w-(--button-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-data-[placement=bottom]:rotate-180 group-data-[placement=left]:-rotate-90 group-data-[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/page/chat/header.ts b/apps/web-foldkit/src/page/chat/header.ts index 7aea6a378..330204944 100644 --- a/apps/web-foldkit/src/page/chat/header.ts +++ b/apps/web-foldkit/src/page/chat/header.ts @@ -35,7 +35,6 @@ const ROW = "flex h-14 shrink-0 items-center justify-between border-border borde export const pinnedButton = <M>( h: HtmlBuilder<M>, attributes: ReadonlyArray<Attribute<M> | ChildAttribute>, - overlay: Html, ) => button( h, @@ -44,7 +43,7 @@ export const pinnedButton = <M>( size: "sm", attributes: [h.Attribute("aria-label", "View pinned messages"), ...attributes], }, - [IconPin(h, { attributes: { "data-slot": "icon" } }), overlay], + [IconPin(h, { attributes: { "data-slot": "icon" } })], ) const threadTitle = <M>(h: HtmlBuilder<M>, inputs: HeaderInputs, channel: ChannelInfo): Html => diff --git a/apps/web-foldkit/src/page/chat/pinned.ts b/apps/web-foldkit/src/page/chat/pinned.ts index fc1e9207a..f676eb66f 100644 --- a/apps/web-foldkit/src/page/chat/pinned.ts +++ b/apps/web-foldkit/src/page/chat/pinned.ts @@ -141,7 +141,7 @@ export const pinnedPopoverView = <M>( model: popover, view: Popover.view, viewInputs: { - toTrigger: (attributes, overlay) => pinnedButton(h, attributes, overlay), + toTrigger: (attributes) => pinnedButton(h, attributes), toContent: () => (popover.isOpen ? content(h, pins) : []), placement: "bottom end", className: "w-96 p-0", diff --git a/apps/web-foldkit/src/page/chat/row-context.ts b/apps/web-foldkit/src/page/chat/row-context.ts index 014ceceea..d2fe9a626 100644 --- a/apps/web-foldkit/src/page/chat/row-context.ts +++ b/apps/web-foldkit/src/page/chat/row-context.ts @@ -134,7 +134,7 @@ const buildIdleContext = ( toOverlays(Overlays.Message.GotPopoverMessage({ messageId: row.message.id, message })), placement: "right top", className: "w-72 p-0 lg:w-80", - toTrigger: (attributes, overlay) => avatarButton(h, row, attributes, overlay), + toTrigger: (attributes) => avatarButton(h, row, attributes), toContent: () => [], }), } @@ -182,7 +182,7 @@ const buildOwnerContext = (h: H, model: Model, nowMs: number): RowContext<Messag toOverlays(Overlays.Message.GotPopoverMessage({ messageId: row.message.id, message })), placement: "right top", className: "w-72 p-0 lg:w-80", - toTrigger: (attributes, overlay) => avatarButton(h, row, attributes, overlay), + toTrigger: (attributes) => avatarButton(h, row, attributes), toContent: () => { const user = users.find((candidate) => candidate.id === row.message.authorId) if (user === undefined) return [] diff --git a/apps/web-foldkit/src/ui/popover.scene.test.ts b/apps/web-foldkit/src/ui/popover.scene.test.ts index c9a4dbe6f..579750a43 100644 --- a/apps/web-foldkit/src/ui/popover.scene.test.ts +++ b/apps/web-foldkit/src/ui/popover.scene.test.ts @@ -3,13 +3,21 @@ import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" import { dialogClose, dialogDescription, dialogTitle } from "./dialog" -import { init, Message, PortalPopover, popoverFooter, update, view } from "./popover" +import { + AnchorPopover, + FocusButton, + init, + Message, + PortalPopoverBackdrop, + popoverFooter, + update, + view, +} from "./popover" -/** Popover through the view: trigger state, the dialog labelled by its trigger, close and Escape. */ +/** Popover through the view: the trigger, the dialog it labels, close, Escape and the backdrop. */ -// The overlay renders beside the trigger: scene clicks bubble through the vdom, not the portal. const sceneView = Scene.withViewInputs(view, { - toTrigger: (attributes, overlay) => ih.div([], [ih.button([...attributes], ["Details"]), overlay]), + toTrigger: (attributes) => ih.button([...attributes], ["Details"]), toContent: (closeAttributes) => [ dialogTitle(ih, {}, "Notifications"), dialogDescription(ih, "Choose when this channel notifies you."), @@ -20,10 +28,18 @@ const sceneView = Scene.withViewInputs(view, { const config = { update, view: sceneView() } const trigger = Scene.role("button", { name: "Details" }) const popover = Scene.role("dialog") -const opened = [ +const opened = () => [ Scene.given(init("details")), Scene.click(trigger), - Scene.Mount.resolve(PortalPopover, Message.CompletedPortalPopover()), + Scene.Mount.resolve(PortalPopoverBackdrop, Message.CompletedPortalPopoverBackdrop()), + Scene.Mount.resolve(AnchorPopover, Message.CompletedAnchorPopover()), +] +const closed = () => [ + Scene.Command.resolve(FocusButton, Message.CompletedFocusButton()), + Scene.Mount.expectEnded(PortalPopoverBackdrop), + Scene.Mount.expectEnded(AnchorPopover), + Scene.expect(popover).toBeAbsent(), + Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), ] describe("popover scene", () => { @@ -32,7 +48,7 @@ describe("popover scene", () => { config, Scene.given(init("details")), Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), - Scene.expect(trigger).toHaveId("details-trigger"), + Scene.expect(trigger).toHaveId("details-button"), Scene.expect(popover).toBeAbsent(), ) }) @@ -40,79 +56,23 @@ describe("popover scene", () => { test("pressing the trigger opens a dialog the trigger labels and controls", () => { Scene.scene( config, - Scene.given(init("details")), - Scene.click(trigger), - Scene.Mount.expectExact(PortalPopover({ id: "details", placement: "bottom", offset: 8 })), - Scene.Mount.resolve(PortalPopover, Message.CompletedPortalPopover()), + ...opened(), Scene.expect(trigger).toHaveAttr("aria-expanded", "true"), - Scene.expect(trigger).toHaveAttr("aria-controls", "details-popover"), - Scene.expect(popover).toHaveAttr("aria-labelledby", "details-trigger"), - Scene.expect(Scene.role("dialog", { name: "Details" })).toHaveId("details-popover"), + Scene.expect(trigger).toHaveAttr("aria-controls", "details-panel"), + Scene.expect(Scene.role("dialog", { name: "Details" })).toHaveId("details-panel"), Scene.expect(Scene.role("heading", { name: "Notifications", level: 3 })).toExist(), ) }) test("the close button in the content closes the popover", () => { - Scene.scene( - config, - ...opened, - Scene.click(Scene.role("button", { name: "Close" })), - Scene.Mount.expectEnded(PortalPopover), - Scene.expect(popover).toBeAbsent(), - Scene.expect(trigger).toHaveAttr("aria-expanded", "false"), - ) - }) - - test("Escape inside the popover closes it", () => { - Scene.scene( - config, - ...opened, - Scene.keydown(popover, "Escape"), - Scene.expectHandled(), - Scene.Mount.expectEnded(PortalPopover), - Scene.expect(popover).toBeAbsent(), - ) + Scene.scene(config, ...opened(), Scene.click(Scene.role("button", { name: "Close" })), ...closed()) }) - test("other keys inside the popover fall through", () => { - Scene.scene( - config, - ...opened, - Scene.keydown(popover, "Tab"), - Scene.expectIgnored(), - Scene.expect(popover).toExist(), - ) + test("Escape in the popover closes it", () => { + Scene.scene(config, ...opened(), Scene.keydown(popover, "Escape"), Scene.expectHandled(), ...closed()) }) - test("the hidden dismiss buttons at both ends close the popover for screen readers", () => { - Scene.scene( - config, - ...opened, - Scene.expectAll(Scene.all.role("button", { name: "Dismiss" })).toHaveCount(2), - Scene.click(Scene.first(Scene.all.role("button", { name: "Dismiss" }))), - Scene.Mount.expectEnded(PortalPopover), - Scene.expect(popover).toBeAbsent(), - ) - }) - - test("a press outside dismisses the popover", () => { - Scene.scene( - config, - ...opened, - Scene.Subscription.emit(Message.PressedOutside()), - Scene.Mount.expectEnded(PortalPopover), - Scene.expect(popover).toBeAbsent(), - ) - }) - - test("a popover with an arrow uses the larger offset and the requested placement", () => { - Scene.scene( - { update, view: sceneView({ arrow: true, placement: "right" }) }, - Scene.given(init("details")), - Scene.click(trigger), - Scene.Mount.expectExact(PortalPopover({ id: "details", placement: "right", offset: 12 })), - Scene.Mount.resolve(PortalPopover, Message.CompletedPortalPopover()), - Scene.expect(Scene.selector("svg")).toExist(), - ) + test("pressing the trigger again closes it", () => { + Scene.scene(config, ...opened(), Scene.click(trigger), ...closed()) }) }) diff --git a/apps/web-foldkit/src/ui/popover.story.test.ts b/apps/web-foldkit/src/ui/popover.story.test.ts index dc274cd9e..7e8ea6d2b 100644 --- a/apps/web-foldkit/src/ui/popover.story.test.ts +++ b/apps/web-foldkit/src/ui/popover.story.test.ts @@ -1,85 +1,38 @@ // @vitest-environment jsdom -import { Command, given, message, model, story } from "foldkit/story" +import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { init, Message, update } from "./popover" +import { FocusButton, init, Message, OutMessage, update } from "./popover" -/** React Aria DialogTrigger + Popover: the trigger toggles, close, Escape and outside presses dismiss. */ +/** Popover on @foldkit/ui Popover: open and close, focus back to the trigger on close. */ const closedPopover = init("details") const openPopover = { ...closedPopover, isOpen: true } describe("popover story", () => { - test("a new popover starts closed", () => { - story( - update, - given(closedPopover), - model((next) => expect(next).toEqual({ id: "details", isOpen: false })), - ) + test("a new popover starts closed and hands focus to its content", () => { + expect(closedPopover.isOpen).toBe(false) + expect(closedPopover.contentFocus).toBe(true) }) - test("pressing the trigger opens the popover", () => { + test("a request to open opens it and emits Opened", () => { story( update, given(closedPopover), - message(Message.ClickedTrigger()), + message(Message.RequestedOpen()), + expectOutMessage(OutMessage.Opened()), Command.expectNone(), model((next) => expect(next.isOpen).toBe(true)), ) }) - test("pressing the trigger of an open popover closes it", () => { - story( - update, - given(openPopover), - message(Message.ClickedTrigger()), - model((next) => expect(next.isOpen).toBe(false)), - ) - }) - - test("a close button closes the popover", () => { - story( - update, - given(openPopover), - message(Message.ClickedClose()), - model((next) => expect(next.isOpen).toBe(false)), - ) - }) - - test("Escape closes the popover", () => { + test("a request to close closes it, focuses the trigger and emits Closed", () => { story( update, given(openPopover), - message(Message.PressedEscape()), + message(Message.RequestedClose()), + expectOutMessage(OutMessage.Closed()), + Command.resolve(FocusButton, Message.CompletedFocusButton()), model((next) => expect(next.isOpen).toBe(false)), ) }) - - test("a press outside closes the popover", () => { - story( - update, - given(openPopover), - message(Message.PressedOutside()), - model((next) => expect(next.isOpen).toBe(false)), - ) - }) - - test("dismissing an already closed popover keeps it closed", () => { - story( - update, - given(closedPopover), - message(Message.PressedOutside()), - message(Message.PressedEscape()), - model((next) => expect(next.isOpen).toBe(false)), - ) - }) - - test("the portal finishing its mount leaves the popover as it was", () => { - story( - update, - given(openPopover), - message(Message.CompletedPortalPopover()), - Command.expectNone(), - model((next) => expect(next).toEqual(openPopover)), - ) - }) }) diff --git a/apps/web-foldkit/src/ui/popover.ts b/apps/web-foldkit/src/ui/popover.ts index fd5674293..75632043c 100644 --- a/apps/web-foldkit/src/ui/popover.ts +++ b/apps/web-foldkit/src/ui/popover.ts @@ -1,87 +1,51 @@ -import { Effect, Option, Queue, Schema, Stream } from "effect" -import { Mount, Submodel, type Update } from "foldkit" +import { Popover } from "@foldkit/ui" +import { Submodel } from "foldkit" import { type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { modifyFields } from "foldkit/struct" import { twMerge } from "tailwind-merge" import { popoverArrowClassName, + popoverBackdropClassName, popoverContentBase, popoverFooterBase, popoverInnerClassName, } from "~/components/ui/popover.styles" -import { dismissButton, observeDialogParts, openModalPopover } from "./aria/overlay" -import { Placement } from "./aria/placement" +import { type Placement, toAnchorPlacement } from "./aria/placement" import { dialogFooter } from "./dialog" -/** Port of `components/ui/popover.tsx` (React Aria DialogTrigger + Popover). */ +/** + * A dialog popover on @foldkit/ui Popover: the trigger toggles it, Floating UI anchors the panel, + * Escape and a press on the backdrop close it and focus returns to the trigger. Focus moves to the + * content on open (`contentFocus`), so pressing a control inside it keeps the popover open. + */ // MODEL -export const Model = Schema.Struct({ id: Schema.String, isOpen: Schema.Boolean }) -export type Model = typeof Model.Type +export const Model = Popover.Model +export type Model = Popover.Model -export const init = (id: string): Model => ({ id, isOpen: false }) +export const init = (id: string): Model => Popover.init({ id, contentFocus: true }) // MESSAGE -export const Message = defineMessageUnion({ - ClickedTrigger: {}, - ClickedClose: {}, - PressedEscape: {}, - PressedOutside: {}, - CompletedPortalPopover: {}, -}) -export type Message = typeof Message.Type +export const Message = Popover.Message +export type Message = Popover.Message -export const triggerId = (id: string) => `${id}-trigger` -export const popoverId = (id: string) => `${id}-popover` +export const OutMessage = Popover.OutMessage +export type OutMessage = Popover.OutMessage -// UPDATE +export const AnchorPopover = Popover.AnchorPopover +export const PortalPopoverBackdrop = Popover.PortalPopoverBackdrop +export const FocusButton = Popover.FocusButton -const closed = (model: Model): Model => modifyFields(model, { isOpen: () => false }) +export const triggerId = Popover.buttonId +export const popoverId = (id: string) => `${id}-panel` +const contentId = (id: string) => `${id}-content` -export const update = (model: Model, message: Message): Update.Return<Model, Message> => - Message.match<Update.Return<Model, Message>>(message, { - ClickedTrigger: () => ({ model: modifyFields(model, { isOpen: () => !model.isOpen }) }), - ClickedClose: () => ({ model: closed(model) }), - PressedEscape: () => ({ model: closed(model) }), - PressedOutside: () => ({ model: closed(model) }), - CompletedPortalPopover: () => ({ model }), - }) +// UPDATE -// MOUNT - -type PortalPopoverMessage = Extract<Message, { _tag: "CompletedPortalPopover" | "PressedOutside" }> - -export const PortalPopover = Mount.defineStream("PortalPopover", { - args: { id: Schema.String, placement: Placement, offset: Schema.Number }, - messages: [Message.CompletedPortalPopover, Message.PressedOutside], - execute: ({ element, id, placement, offset }) => - Stream.callback<PortalPopoverMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const release = openModalPopover(element, { - triggerId: triggerId(id), - placement, - offset, - arrowSelector: ":scope > .group", - isTriggerWidthSet: true, - initialFocusId: popoverId(id), - insideSelector: `#${CSS.escape(popoverId(id))}`, - onInteractOutside: () => Queue.offerUnsafe(queue, Message.PressedOutside()), - }) - const releaseParts = observeDialogParts(element) - Queue.offerUnsafe(queue, Message.CompletedPortalPopover()) - return () => { - releaseParts() - release() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ), -}) +export const update = Popover.update +export const open = Popover.open +export const close = Popover.close // VIEW @@ -93,8 +57,8 @@ export const popoverFooter = <M>( ): Html => dialogFooter(h, children, twMerge(popoverFooterBase, className)) export type ViewInputs = Readonly<{ - /** Renders the trigger (DialogTrigger child). Spread `attributes` on it and put `overlay` last. */ - toTrigger: (attributes: ReadonlyArray<ChildAttribute>, overlay: Html) => Html + /** Renders the trigger button; spread `attributes` on it. The panel renders beside it. */ + toTrigger: (attributes: ReadonlyArray<ChildAttribute>) => Html /** Popover content; spread `closeAttributes` on PopoverClose buttons. */ toContent: (closeAttributes: ReadonlyArray<ChildAttribute>) => ReadonlyArray<Html> placement?: Placement @@ -105,97 +69,69 @@ export type ViewInputs = Readonly<{ const ARROW_OFFSET = 12 const OFFSET = 8 +const VIEWPORT_PADDING = 12 export const view = Submodel.defineView<Model, Message, ViewInputs>((model, viewInputs, h) => { - const triggerAttributes = childAttributes([ - ...(model.isOpen ? [h.Attribute("aria-controls", popoverId(model.id))] : []), - h.Attribute("aria-expanded", model.isOpen ? "true" : "false"), - ...(model.isOpen ? [h.Attribute("data-pressed", "true")] : []), - h.Id(triggerId(model.id)), - h.OnClick(Message.ClickedTrigger()), - ]) - return viewInputs.toTrigger( - triggerAttributes, - model.isOpen ? popoverOverlay(model, viewInputs, h) : h.empty, - ) -}) - -const popoverOverlay = (model: Model, viewInputs: ViewInputs, h: HtmlBuilder<Message>): Html => { const arrow = viewInputs.arrow ?? false - const closeAttributes = childAttributes([h.OnClick(Message.ClickedClose())]) - return h.div( - [ - h.Attribute("style", "display: contents;"), - h.OnMount( - PortalPopover({ - id: model.id, - placement: viewInputs.placement ?? "bottom", - offset: viewInputs.offset ?? (arrow ? ARROW_OFFSET : OFFSET), - }), - ), - ], - [ - h.span([ - h.Attribute("data-focus-scope-start", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - h.div([ - h.Attribute("inert", ""), - h.Attribute("data-testid", "underlay"), - h.Attribute("style", "position: fixed; inset: 0px;"), - ]), - h.div( - [h.Attribute("style", "display: contents;")], - [ - h.div( - [ - h.Attribute("aria-labelledby", triggerId(model.id)), - h.Class(twMerge(twMerge(...popoverContentBase), viewInputs.className)), - h.Attribute("data-popover", ""), - h.Attribute("data-trigger", "DialogTrigger"), - h.Attribute("dir", "ltr"), - h.Id(popoverId(model.id)), - h.Role("dialog"), - h.Attribute("tabindex", "-1"), - h.OnKeyDownPreventDefault((key) => - key === "Escape" ? Option.some(Message.PressedEscape()) : Option.none(), - ), - ], - [ - dismissButton(h, Message.ClickedClose()), - ...(arrow - ? [ - h.div( - [h.Class("group")], - [ - h.svg( - [ - h.Attribute("width", "12"), - h.Attribute("height", "12"), - h.Attribute("viewBox", "0 0 12 12"), - h.Class(popoverArrowClassName), - ], - [h.path([h.Attribute("d", "M0 0 L6 6 L12 0")])], - ), - ], - ), - ] - : []), - h.div( - [h.Attribute("data-slot", "popover-inner"), h.Class(popoverInnerClassName)], - viewInputs.toContent(closeAttributes), - ), - dismissButton(h, Message.ClickedClose()), - ], - ), - ], - ), - h.span([ - h.Attribute("data-focus-scope-end", "true"), - h.Attribute("hidden", ""), - h.Attribute("inert", ""), - ]), - ], - ) -} + return h.submodel({ + slotId: model.id, + model, + view: Popover.view, + viewInputs: { + anchor: { + placement: toAnchorPlacement(viewInputs.placement ?? "bottom"), + gap: viewInputs.offset ?? (arrow ? ARROW_OFFSET : OFFSET), + padding: VIEWPORT_PADDING, + }, + focusSelector: `#${contentId(model.id)}`, + toView: (render: Popover.RenderInfo) => { + const closeAttributes = childAttributes([h.Type("button"), h.OnClick(Message.RequestedClose())]) + return h.div( + [h.Class("contents")], + [ + viewInputs.toTrigger(render.button), + ...(render.isVisible + ? [ + h.div([...render.backdrop, h.Class(popoverBackdropClassName)]), + h.div( + [ + ...render.panel, + h.Class(twMerge(...popoverContentBase, viewInputs.className)), + h.Role("dialog"), + h.AriaLabelledBy(triggerId(model.id)), + ], + [ + ...(arrow + ? [ + h.svg( + [ + ...render.arrow, + h.Attribute("width", "12"), + h.Attribute("height", "12"), + h.Attribute("viewBox", "0 0 12 12"), + h.Class(popoverArrowClassName), + ], + [h.path([h.Attribute("d", "M0 0 L6 6 L12 0")])], + ), + ] + : []), + h.div( + [ + h.Id(contentId(model.id)), + h.Tabindex(-1), + h.DataAttribute("slot", "popover-inner"), + h.Class(popoverInnerClassName), + ], + viewInputs.toContent(closeAttributes), + ), + ], + ), + ] + : []), + ], + ) + }, + }, + toParentMessage: (message) => message, + }) +}) From c1cf0c3302441f8f845c921aea32b463f77f8668 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:16:44 +0200 Subject: [PATCH 50/52] refactor(web): modal open and close are Model changes The dialog renders only while open and its Mount shows it and releases it (focus return included) on removal, so ShowDialog/CloseDialog were redundant Commands. open/close/update now only flip isOpen and emit Opened/Closed; scene tests resolve the dialog's AcquireResources Mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/core/components/ui/sheet.styles.ts | 1 - .../my-settings/profile/profile.scene.test.ts | 4 +- .../chat-sync-connection/scene.test.ts | 4 +- .../src/page/settings/chat-sync/scene.test.ts | 4 +- apps/web-foldkit/src/ui/aria/position.ts | 852 ------------------ apps/web-foldkit/src/ui/modal.scene.test.ts | 3 +- apps/web-foldkit/src/ui/modal.story.test.ts | 14 +- apps/web-foldkit/src/ui/modal.ts | 45 +- apps/web-foldkit/src/ui/sheet.scene.test.ts | 5 +- apps/web-foldkit/src/ui/sheet.ts | 4 +- 10 files changed, 51 insertions(+), 885 deletions(-) delete mode 100644 apps/web-foldkit/src/ui/aria/position.ts 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 77fc02a81..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,4 +1,3 @@ -import { twJoin } from "tailwind-merge" import { tv } from "tailwind-variants" export type Sides = "top" | "bottom" | "left" | "right" diff --git a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts index 564063a50..4f36af8a3 100644 --- a/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/profile/profile.scene.test.ts @@ -90,8 +90,8 @@ const dialog = Scene.role("dialog") /** The crop dialog is a `controlledModal`, so its Mount result arrives already lifted into the page Message. */ const cropModalMounted = Scene.Mount.resolve( // Name-only matcher: the Mount is wrapped by mapMessage, so its typed result is the parent Message. - { name: "PortalModal" }, - Message.GotCropModalMessage({ message: Modal.Message.SucceededAcquireResources() }), + { name: "AcquireResources" }, + Modal.Message.SucceededAcquireResources(), ) const toastOf = (intent: "success" | "error", title: string, description: string | null = null) => PageOutMessage.RequestedToast({ toast: { intent, title, description } }) diff --git a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts index 62ebf4509..d973f0443 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync-connection/scene.test.ts @@ -69,8 +69,8 @@ const inDialog = (name: string | RegExp) => Scene.within(dialog, Scene.role("but const linkMenuMounted = Scene.Mount.resolve(FocusTriggerOnPress, Menu.Message.CompletedFocusTriggerOnPress()) /** The add link modal is a controlled modal: its Mount result arrives already wrapped. */ const addLinkModalMounted = Scene.Mount.resolve( - { name: "PortalModal" }, - Message.GotAddLinkModalMessage({ message: Modal.Message.SucceededAcquireResources() }), + { name: "AcquireResources" }, + Modal.Message.SucceededAcquireResources(), ) describe("connection states", () => { diff --git a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts index eb3a85ae7..805c83cf5 100644 --- a/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/chat-sync/scene.test.ts @@ -43,8 +43,8 @@ const addTriggerMounted = Scene.Mount.resolve( Menu.Message.CompletedFocusTriggerOnPress(), ) const addModalMounted = Scene.Mount.resolve( - { name: "PortalModal" }, - Message.GotAddModalMessage({ message: Modal.Message.SucceededAcquireResources() }), + { name: "AcquireResources" }, + Modal.Message.SucceededAcquireResources(), ) describe("list states", () => { diff --git a/apps/web-foldkit/src/ui/aria/position.ts b/apps/web-foldkit/src/ui/aria/position.ts deleted file mode 100644 index 199b7bd3d..000000000 --- a/apps/web-foldkit/src/ui/aria/position.ts +++ /dev/null @@ -1,852 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -// @ts-nocheck -// Vendored from @react-aria/overlays 3.31.2 (src/calculatePosition.ts) so Foldkit overlays land on -// exactly the pixel React Aria picks. Only the imports below differ from upstream. - -type PlacementAxis = "top" | "bottom" | "left" | "right" | "center" -type Axis = "top" | "left" -type SizeAxis = "width" | "height" -export type Placement = - | "bottom" - | "bottom left" - | "bottom right" - | "bottom start" - | "bottom end" - | "top" - | "top left" - | "top right" - | "top start" - | "top end" - | "left" - | "left top" - | "left bottom" - | "start" - | "start top" - | "start bottom" - | "right" - | "right top" - | "right bottom" - | "end" - | "end top" - | "end bottom" - -const clamp = (value: number, min = -Infinity, max = Infinity): number => Math.min(Math.max(value, min), max) -const isWebKit = (): boolean => - /AppleWebKit/i.test(navigator.userAgent) && !/Chrome/i.test(navigator.userAgent) -const nodeContains = (node: Node | null, other: Node | null): boolean => - !!node && !!other && node.contains(other) - -interface Position { - top?: number - left?: number - bottom?: number - right?: number -} - -interface Dimensions { - width: number - height: number - totalWidth: number - totalHeight: number - top: number - left: number - scroll: Position -} - -interface ParsedPlacement { - placement: PlacementAxis - crossPlacement: PlacementAxis - axis: Axis - crossAxis: Axis - size: SizeAxis - crossSize: SizeAxis -} - -interface Offset { - top: number - left: number - width: number - height: number -} - -interface PositionOpts { - arrowSize: number - placement: Placement - targetNode: Element - overlayNode: Element - scrollNode: Element - padding: number - shouldFlip: boolean - boundaryElement: Element - offset: number - crossOffset: number - maxHeight?: number - arrowBoundaryOffset?: number -} - -type HeightGrowthDirection = "top" | "bottom" - -export interface PositionResult { - position: Position - arrowOffsetLeft?: number - arrowOffsetTop?: number - triggerAnchorPoint: { x: number; y: number } - maxHeight: number - placement: PlacementAxis -} - -const AXIS = { - top: "top", - bottom: "top", - left: "left", - right: "left", -} - -const FLIPPED_DIRECTION = { - top: "bottom", - bottom: "top", - left: "right", - right: "left", -} - -const CROSS_AXIS = { - top: "left", - left: "top", -} - -const AXIS_SIZE = { - top: "height", - left: "width", -} - -const TOTAL_SIZE = { - width: "totalWidth", - height: "totalHeight", -} - -const PARSED_PLACEMENT_CACHE = {} - -let getVisualViewport = () => (typeof document !== "undefined" ? window.visualViewport : null) - -function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions { - let width = 0, - height = 0, - totalWidth = 0, - totalHeight = 0, - top = 0, - left = 0 - let scroll: Position = {} - let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1 - - // In the case where the container is `html` or `body` and the container doesn't have something like `position: relative`, - // then position absolute will be positioned relative to the viewport, also known as the `initial containing block`. - // That's why we use the visual viewport instead. - - if (containerNode.tagName === "BODY" || containerNode.tagName === "HTML") { - let documentElement = document.documentElement - totalWidth = documentElement.clientWidth - totalHeight = documentElement.clientHeight - width = visualViewport?.width ?? totalWidth - height = visualViewport?.height ?? totalHeight - scroll.top = documentElement.scrollTop || containerNode.scrollTop - scroll.left = documentElement.scrollLeft || containerNode.scrollLeft - - // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with - // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate - // coordinates/values with respect to the visual viewport or with respect to the layout viewport. - if (visualViewport) { - top = visualViewport.offsetTop - left = visualViewport.offsetLeft - } - } else { - ;({ width, height, top, left } = getOffset(containerNode, false)) - scroll.top = containerNode.scrollTop - scroll.left = containerNode.scrollLeft - totalWidth = width - totalHeight = height - } - - if ( - isWebKit() && - (containerNode.tagName === "BODY" || containerNode.tagName === "HTML") && - isPinchZoomedIn - ) { - // Safari will report a non-zero scrollTop/Left for the non-scrolling body/HTML element when pinch zoomed in unlike other browsers. - // Set to zero for parity calculations so we get consistent positioning of overlays across all browsers. - // Also switch to visualViewport.pageTop/pageLeft so that we still accomodate for scroll positioning for body/HTML elements that are actually scrollable - // before pinch zoom happens - scroll.top = 0 - scroll.left = 0 - top = visualViewport?.pageTop ?? 0 - left = visualViewport?.pageLeft ?? 0 - } - - return { width, height, totalWidth, totalHeight, scroll, top, left } -} - -function getScroll(node: Element): Offset { - return { - top: node.scrollTop, - left: node.scrollLeft, - width: node.scrollWidth, - height: node.scrollHeight, - } -} - -// Determines the amount of space required when moving the overlay to ensure it remains in the boundary -function getDelta( - axis: Axis, - offset: number, - size: number, - // The dimensions of the boundary element that the popover is - // positioned within (most of the time this is the <body>). - boundaryDimensions: Dimensions, - // The dimensions of the containing block element that the popover is - // positioned relative to (e.g. parent with position: relative). - // Usually this is the same as the boundary element, but if the popover - // is portaled somewhere other than the body and has an ancestor with - // position: relative/absolute, it will be different. - containerDimensions: Dimensions, - padding: number, - containerOffsetWithBoundary: Offset, -) { - let containerScroll = containerDimensions.scroll[axis] ?? 0 - // The height/width of the boundary. Matches the axis along which we are adjusting the overlay position - let boundarySize = boundaryDimensions[AXIS_SIZE[axis]] - // Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay. - // Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport) - - let boundaryStartEdge = - containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + padding - let boundaryEndEdge = - containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + boundarySize - padding - // transformed value of the left edge of the overlay - let startEdgeOffset = - offset - - containerScroll + - boundaryDimensions.scroll[AXIS[axis]] + - containerOffsetWithBoundary[axis] - - boundaryDimensions[AXIS[axis]] - // transformed value of the right edge of the overlay - let endEdgeOffset = - offset - - containerScroll + - size + - boundaryDimensions.scroll[AXIS[axis]] + - containerOffsetWithBoundary[axis] - - boundaryDimensions[AXIS[axis]] - - // If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's - // edges with the closest boundary edge. - if (startEdgeOffset < boundaryStartEdge) { - return boundaryStartEdge - startEdgeOffset - } else if (endEdgeOffset > boundaryEndEdge) { - return Math.max(boundaryEndEdge - endEdgeOffset, boundaryStartEdge - startEdgeOffset) - } else { - return 0 - } -} - -function getMargins(node: Element): Position { - let style = window.getComputedStyle(node) - return { - top: parseInt(style.marginTop, 10) || 0, - bottom: parseInt(style.marginBottom, 10) || 0, - left: parseInt(style.marginLeft, 10) || 0, - right: parseInt(style.marginRight, 10) || 0, - } -} - -function parsePlacement(input: Placement): ParsedPlacement { - if (PARSED_PLACEMENT_CACHE[input]) { - return PARSED_PLACEMENT_CACHE[input] - } - - let [placement, crossPlacement] = input.split(" ") - let axis: Axis = AXIS[placement] || "right" - let crossAxis: Axis = CROSS_AXIS[axis] - - if (!AXIS[crossPlacement]) { - crossPlacement = "center" - } - - let size = AXIS_SIZE[axis] - let crossSize = AXIS_SIZE[crossAxis] - PARSED_PLACEMENT_CACHE[input] = { placement, crossPlacement, axis, crossAxis, size, crossSize } - return PARSED_PLACEMENT_CACHE[input] -} - -function computePosition( - childOffset: Offset, - boundaryDimensions: Dimensions, - overlaySize: Offset, - placementInfo: ParsedPlacement, - offset: number, - crossOffset: number, - containerOffsetWithBoundary: Offset, - isContainerPositioned: boolean, - arrowSize: number, - arrowBoundaryOffset: number, - containerDimensions: Dimensions, -) { - let { placement, crossPlacement, axis, crossAxis, size, crossSize } = placementInfo - let position: Position = {} - - // button position - position[crossAxis] = childOffset[crossAxis] ?? 0 - if (crossPlacement === "center") { - // + (button size / 2) - (overlay size / 2) - // at this point the overlay center should match the button center - position[crossAxis]! += ((childOffset[crossSize] ?? 0) - (overlaySize[crossSize] ?? 0)) / 2 - } else if (crossPlacement !== crossAxis) { - // + (button size) - (overlay size) - // at this point the overlay bottom should match the button bottom - position[crossAxis]! += (childOffset[crossSize] ?? 0) - (overlaySize[crossSize] ?? 0) - } /* else { - the overlay top should match the button top - } */ - - position[crossAxis]! += crossOffset - - // overlay top or left overlapping arrow with button bottom or right - const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset - // overlay bottom or right overlapping arrow with button top or left - const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset - position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition) - - // Floor these so the position isn't placed on a partial pixel, only whole pixels. Shouldn't matter if it was floored or ceiled, so chose one. - if (placement === axis) { - // If the container is positioned (non-static), then we use the container's actual - // height, as `bottom` will be relative to this height. But if the container is static, - // then it can only be the `document.body`, and `bottom` will be relative to _its_ - // container. - let containerHeight = isContainerPositioned - ? containerDimensions[size] - : containerDimensions[TOTAL_SIZE[size]] - position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset) - } else { - position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset) - } - return position -} - -function getMaxHeight( - position: Position, - boundaryDimensions: Dimensions, - containerOffsetWithBoundary: Offset, - isContainerPositioned: boolean, - margins: Position, - padding: number, - overlayHeight: number, - heightGrowthDirection: HeightGrowthDirection, - containerDimensions: Dimensions, - isContainerDescendentOfBoundary: boolean, - visualViewport: VisualViewport | null, -) { - // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top - // with respect to the container. - let overlayTop = - (position.top != null - ? position.top - : containerDimensions[TOTAL_SIZE.height] - (position.bottom ?? 0) - overlayHeight) - - (containerDimensions.scroll.top ?? 0) - // calculate the dimentions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port - let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary - ? containerOffsetWithBoundary.top - : 0 - let boundingRect = { - // This should be boundary top in container coord system vs viewport top in container coord system - // For the viewport top, there are several cases - // 1. pinchzoom case where we want the viewports offset top as top here - // 2. case where container is offset from the boundary and is contained by the boundary. In this case the top we want here is NOT 0, we want to take boundary's top even though is is a negative number OR the visual viewport, whichever is more restrictive - top: Math.max( - boundaryDimensions.top + boundaryToContainerTransformOffset, - (visualViewport?.offsetTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset, - ), - bottom: Math.min( - boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset, - (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0), - ), - } - let maxHeight = - heightGrowthDirection !== "top" - ? // We want the distance between the top of the overlay to the bottom of the boundary - Math.max( - 0, - boundingRect.bottom - // this is the bottom of the boundary - overlayTop - // this is the top of the overlay - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding), // save additional space for margin and padding - ) - : // We want the distance between the bottom of the overlay to the top of the boundary - Math.max( - 0, - overlayTop + - overlayHeight - // this is the bottom of the overlay - boundingRect.top - // this is the top of the boundary - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding), // save additional space for margin and padding - ) - return maxHeight -} - -function getAvailableSpace( - boundaryDimensions: Dimensions, // boundary - containerOffsetWithBoundary: Offset, - childOffset: Offset, // trigger, position based of container's non-viewport 0,0 - margins: Position, // overlay - padding: number, // overlay <-> boundary - placementInfo: ParsedPlacement, - containerDimensions: Dimensions, - isContainerDescendentOfBoundary: boolean, -) { - let { placement, axis, size } = placementInfo - if (placement === axis) { - return Math.max( - 0, - childOffset[axis] - // trigger start - (containerDimensions.scroll[axis] ?? 0) - // transform trigger position to be with respect to viewport 0,0 - (boundaryDimensions[axis] + - (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) - // boundary start - (margins[axis] ?? 0) - // margins usually for arrows or other decorations - margins[FLIPPED_DIRECTION[axis]] - - padding, - ) // padding between overlay and boundary - } - - return Math.max( - 0, - boundaryDimensions[size] + - boundaryDimensions[axis] + - (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0) - - childOffset[axis] - - childOffset[size] + - (containerDimensions.scroll[axis] ?? 0) - - (margins[axis] ?? 0) - - margins[FLIPPED_DIRECTION[axis]] - - padding, - ) -} - -export function calculatePositionInternal( - placementInput: Placement, - childOffset: Offset, - overlaySize: Offset, - scrollSize: Offset, - margins: Position, - padding: number, - flip: boolean, - boundaryDimensions: Dimensions, - containerDimensions: Dimensions, - containerOffsetWithBoundary: Offset, - offset: number, - crossOffset: number, - isContainerPositioned: boolean, - userSetMaxHeight: number | undefined, - arrowSize: number, - arrowBoundaryOffset: number, - isContainerDescendentOfBoundary: boolean, - visualViewport: VisualViewport | null, -): PositionResult { - let placementInfo = parsePlacement(placementInput) - let { size, crossAxis, crossSize, placement, crossPlacement } = placementInfo - let position = computePosition( - childOffset, - boundaryDimensions, - overlaySize, - placementInfo, - offset, - crossOffset, - containerOffsetWithBoundary, - isContainerPositioned, - arrowSize, - arrowBoundaryOffset, - containerDimensions, - ) - let normalizedOffset = offset - let space = getAvailableSpace( - boundaryDimensions, - containerOffsetWithBoundary, - childOffset, - margins, - padding + offset, - placementInfo, - containerDimensions, - isContainerDescendentOfBoundary, - ) - - // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip - if (flip && overlaySize[size] > space) { - let flippedPlacementInfo = parsePlacement( - `${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement, - ) - let flippedPosition = computePosition( - childOffset, - boundaryDimensions, - overlaySize, - flippedPlacementInfo, - offset, - crossOffset, - containerOffsetWithBoundary, - isContainerPositioned, - arrowSize, - arrowBoundaryOffset, - containerDimensions, - ) - - let flippedSpace = getAvailableSpace( - boundaryDimensions, - containerOffsetWithBoundary, - childOffset, - margins, - padding + offset, - flippedPlacementInfo, - containerDimensions, - isContainerDescendentOfBoundary, - ) - - // If the available space for the flipped position is greater than the original available space, flip. - if (flippedSpace > space) { - placementInfo = flippedPlacementInfo - position = flippedPosition - normalizedOffset = offset - } - } - - // Determine the direction the height of the overlay can grow so that we can choose how to calculate the max height - let heightGrowthDirection: HeightGrowthDirection = "bottom" - if (placementInfo.axis === "top") { - if (placementInfo.placement === "top") { - heightGrowthDirection = "top" - } else if (placementInfo.placement === "bottom") { - heightGrowthDirection = "bottom" - } - } else if (placementInfo.crossAxis === "top") { - if (placementInfo.crossPlacement === "top") { - heightGrowthDirection = "bottom" - } else if (placementInfo.crossPlacement === "bottom") { - heightGrowthDirection = "top" - } - } - - let delta = getDelta( - crossAxis, - position[crossAxis]!, - overlaySize[crossSize], - boundaryDimensions, - containerDimensions, - padding, - containerOffsetWithBoundary, - ) - position[crossAxis]! += delta - - let maxHeight = getMaxHeight( - position, - boundaryDimensions, - containerOffsetWithBoundary, - isContainerPositioned, - margins, - padding, - overlaySize.height, - heightGrowthDirection, - containerDimensions, - isContainerDescendentOfBoundary, - visualViewport, - ) - - if (userSetMaxHeight && userSetMaxHeight < maxHeight) { - maxHeight = userSetMaxHeight - } - - overlaySize.height = Math.min(overlaySize.height, maxHeight) - - position = computePosition( - childOffset, - boundaryDimensions, - overlaySize, - placementInfo, - normalizedOffset, - crossOffset, - containerOffsetWithBoundary, - isContainerPositioned, - arrowSize, - arrowBoundaryOffset, - containerDimensions, - ) - delta = getDelta( - crossAxis, - position[crossAxis]!, - overlaySize[crossSize], - boundaryDimensions, - containerDimensions, - padding, - containerOffsetWithBoundary, - ) - position[crossAxis]! += delta - - let arrowPosition: Position = {} - - // All values are transformed so that 0 is at the top/left of the overlay depending on the orientation - // Prefer the arrow being in the center of the trigger/overlay anchor element - // childOffset[crossAxis] + .5 * childOffset[crossSize] = absolute position with respect to the trigger's coordinate system that would place the arrow in the center of the trigger - // position[crossAxis] - margins[AXIS[crossAxis]] = value use to transform the position to a value with respect to the overlay's coordinate system. A child element's (aka arrow) position absolute's "0" - // is positioned after the margin of its parent (aka overlay) so we need to subtract it to get the proper coordinate transform - let origin = childOffset[crossAxis] - position[crossAxis]! - margins[AXIS[crossAxis]] - let preferredArrowPosition = origin + 0.5 * childOffset[crossSize] - - // Min/Max position limits for the arrow with respect to the overlay - const arrowMinPosition = arrowSize / 2 + arrowBoundaryOffset - // overlaySize[crossSize] - margins = true size of the overlay - const overlayMargin = - AXIS[crossAxis] === "left" - ? (margins.left ?? 0) + (margins.right ?? 0) - : (margins.top ?? 0) + (margins.bottom ?? 0) - const arrowMaxPosition = overlaySize[crossSize] - overlayMargin - arrowSize / 2 - arrowBoundaryOffset - - // Min/Max position limits for the arrow with respect to the trigger/overlay anchor element - // Same margin accomodation done here as well as for the preferredArrowPosition - const arrowOverlappingChildMinEdge = - childOffset[crossAxis] + arrowSize / 2 - (position[crossAxis] + margins[AXIS[crossAxis]]) - const arrowOverlappingChildMaxEdge = - childOffset[crossAxis] + - childOffset[crossSize] - - arrowSize / 2 - - (position[crossAxis] + margins[AXIS[crossAxis]]) - - // Clamp the arrow positioning so that it always is within the bounds of the anchor and the overlay - const arrowPositionOverlappingChild = clamp( - preferredArrowPosition, - arrowOverlappingChildMinEdge, - arrowOverlappingChildMaxEdge, - ) - arrowPosition[crossAxis] = clamp(arrowPositionOverlappingChild, arrowMinPosition, arrowMaxPosition) - - // If there is an arrow, use that as the origin so that animations are smooth. - // Otherwise use the target edge. - ;({ placement, crossPlacement } = placementInfo) - if (arrowSize) { - origin = arrowPosition[crossAxis] - } else if (crossPlacement === "right") { - origin += childOffset[crossSize] - } else if (crossPlacement === "center") { - origin += childOffset[crossSize] / 2 - } - - let crossOrigin = placement === "left" || placement === "top" ? overlaySize[size] : 0 - let triggerAnchorPoint = { - x: placement === "top" || placement === "bottom" ? origin : crossOrigin, - y: placement === "left" || placement === "right" ? origin : crossOrigin, - } - - return { - position, - maxHeight: maxHeight, - arrowOffsetLeft: arrowPosition.left, - arrowOffsetTop: arrowPosition.top, - placement, - triggerAnchorPoint, - } -} - -/** - * Determines where to place the overlay with regards to the target and the position of an optional indicator. - */ -export function calculatePosition(opts: PositionOpts): PositionResult { - let { - placement, - targetNode, - overlayNode, - scrollNode, - padding, - shouldFlip, - boundaryElement, - offset, - crossOffset, - maxHeight, - arrowSize = 0, - arrowBoundaryOffset = 0, - } = opts - - let visualViewport = getVisualViewport() - let container = - overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement - let isViewportContainer = container === document.documentElement - const containerPositionStyle = window.getComputedStyle(container).position - let isContainerPositioned = !!containerPositionStyle && containerPositionStyle !== "static" - let childOffset: Offset = isViewportContainer - ? getOffset(targetNode, false) - : getPosition(targetNode, container, false) - - if (!isViewportContainer) { - let { marginTop, marginLeft } = window.getComputedStyle(targetNode) - childOffset.top += parseInt(marginTop, 10) || 0 - childOffset.left += parseInt(marginLeft, 10) || 0 - } - - let overlaySize: Offset = getOffset(overlayNode, true) - let margins = getMargins(overlayNode) - overlaySize.width += (margins.left ?? 0) + (margins.right ?? 0) - overlaySize.height += (margins.top ?? 0) + (margins.bottom ?? 0) - - let scrollSize = getScroll(scrollNode) - - // Note that due to logic inside getContainerDimensions, for cases where the boundary element is the body, we will return - // a height/width that matches the visual viewport size rather than the body's height/width (aka for zoom it will be zoom adjusted size) - // and a top/left that is adjusted as well (will return the top/left of the zoomed in viewport, or 0,0 for a non-zoomed body) - // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) - let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport) - let containerDimensions = getContainerDimensions(container, visualViewport) - - // There are several difference cases of how to calculate the containerOffsetWithBoundary: - // - boundaryElement is body or HTML and the container is an arbitrary element in the boundary (aka submenu with parent menu as container in v3) - // - boundaryElement and container are both body or HTML element (aka standard popover case) - // - boundaryElement is customized by the user. Container can also be arbitrary (either body/HTML or some other element) - // containerOffsetWithBoundary should always return a value that is the boundary's coordinate offset with respect to the container coord system (container is 0, 0) - let containerOffsetWithBoundary: Offset - if ((boundaryElement.tagName === "BODY" || boundaryElement.tagName === "HTML") && !isViewportContainer) { - // Use getRect instead of getOffset because boundaryDimensions for BODY/HTML is in viewport coordinate space, - // not document coordinate space - let containerRect = getRect(container, false) - // the offset should be negative because if container is at viewport position x,y, then viewport top (aka 0) - // is at position -x,y in container-relative coordinates - containerOffsetWithBoundary = { - top: -(containerRect.top - boundaryDimensions.top), - left: -(containerRect.left - boundaryDimensions.left), - width: 0, - height: 0, - } - } else if ( - (boundaryElement.tagName === "BODY" || boundaryElement.tagName === "HTML") && - isViewportContainer - ) { - // both are the same viewport container, no offset needed - containerOffsetWithBoundary = { top: 0, left: 0, width: 0, height: 0 } - } else { - // This returns the boundary's coordinate with respect to the container. This case captures cases such as when you provide a custom boundary - // like in ScrollingBoundaryContainerExample in Popover.stories. - containerOffsetWithBoundary = getPosition(boundaryElement, container, false) - } - - let isContainerDescendentOfBoundary = nodeContains(boundaryElement, container) - return calculatePositionInternal( - placement, - childOffset, - overlaySize, - scrollSize, - margins, - padding, - shouldFlip, - boundaryDimensions, - containerDimensions, - containerOffsetWithBoundary, - offset, - crossOffset, - isContainerPositioned, - maxHeight, - arrowSize, - arrowBoundaryOffset, - isContainerDescendentOfBoundary, - visualViewport, - ) -} - -export function getRect(node: Element, ignoreScale: boolean) { - let { top, left, width, height } = node.getBoundingClientRect() - - // Use offsetWidth and offsetHeight if this is an HTML element, so that - // the size is not affected by scale transforms. - if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) { - width = node.offsetWidth - height = node.offsetHeight - } - - return { top, left, width, height } -} - -function getOffset(node: Element, ignoreScale: boolean): Offset { - let { top, left, width, height } = getRect(node, ignoreScale) - let { scrollTop, scrollLeft, clientTop, clientLeft } = document.documentElement - return { - top: top + scrollTop - clientTop, - left: left + scrollLeft - clientLeft, - width, - height, - } -} - -function getPosition(node: Element, parent: Element, ignoreScale: boolean): Offset { - let style = window.getComputedStyle(node) - let offset: Offset - if (style.position === "fixed") { - offset = getRect(node, ignoreScale) - } else { - offset = getOffset(node, ignoreScale) - let parentOffset = getOffset(parent, ignoreScale) - let parentStyle = window.getComputedStyle(parent) - parentOffset.top += (parseInt(parentStyle.borderTopWidth, 10) || 0) - parent.scrollTop - parentOffset.left += (parseInt(parentStyle.borderLeftWidth, 10) || 0) - parent.scrollLeft - offset.top -= parentOffset.top - offset.left -= parentOffset.left - } - - offset.top -= parseInt(style.marginTop, 10) || 0 - offset.left -= parseInt(style.marginLeft, 10) || 0 - return offset -} - -// Returns the containing block of an element, which is the element that -// this element will be positioned relative to. -// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block -function getContainingBlock(node: HTMLElement): Element { - // The offsetParent of an element in most cases equals the containing block. - // https://w3c.github.io/csswg-drafts/cssom-view/#dom-htmlelement-offsetparent - let offsetParent = node.offsetParent - - // The offsetParent algorithm terminates at the document body, - // even if the body is not a containing block. Double check that - // and use the documentElement if so. - if ( - offsetParent && - offsetParent === document.body && - window.getComputedStyle(offsetParent).position === "static" && - !isContainingBlock(offsetParent) - ) { - offsetParent = document.documentElement - } - - // TODO(later): handle table elements? - - // The offsetParent can be null if the element has position: fixed, or a few other cases. - // We have to walk up the tree manually in this case because fixed positioned elements - // are still positioned relative to their containing block, which is not always the viewport. - if (offsetParent == null) { - offsetParent = node.parentElement - while (offsetParent && !isContainingBlock(offsetParent)) { - offsetParent = offsetParent.parentElement - } - } - - // Fall back to the viewport. - return offsetParent || document.documentElement -} - -// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block -function isContainingBlock(node: Element): boolean { - let style = window.getComputedStyle(node) - return ( - style.transform !== "none" || - /transform|perspective/.test(style.willChange) || - style.filter !== "none" || - style.contain === "paint" || - ("backdropFilter" in style && style.backdropFilter !== "none") || - ("WebkitBackdropFilter" in style && style.WebkitBackdropFilter !== "none") - ) -} diff --git a/apps/web-foldkit/src/ui/modal.scene.test.ts b/apps/web-foldkit/src/ui/modal.scene.test.ts index ea5ecd15a..98b6a7f53 100644 --- a/apps/web-foldkit/src/ui/modal.scene.test.ts +++ b/apps/web-foldkit/src/ui/modal.scene.test.ts @@ -3,7 +3,7 @@ import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" import { dialogClose, dialogFooter, dialogHeader } from "./dialog" -import { AcquireResources, CloseDialog, init, initOpen, Message, titleId, update, view } from "./modal" +import { AcquireResources, init, initOpen, Message, titleId, update, view } from "./modal" /** Modal through the view: a native dialog labelled by its title, closed by its buttons and backdrop. */ @@ -31,7 +31,6 @@ const opened = [ Scene.Mount.resolve(AcquireResources, Message.SucceededAcquireResources()), ] const closedByRequest = () => [ - Scene.Command.resolve(CloseDialog, Message.CompletedCloseDialog()), Scene.Mount.expectEnded(AcquireResources), Scene.expect(Scene.role("dialog")).toBeAbsent(), ] diff --git a/apps/web-foldkit/src/ui/modal.story.test.ts b/apps/web-foldkit/src/ui/modal.story.test.ts index 83faf90d1..6520f78ff 100644 --- a/apps/web-foldkit/src/ui/modal.story.test.ts +++ b/apps/web-foldkit/src/ui/modal.story.test.ts @@ -2,7 +2,6 @@ import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" import { - CloseDialog, close, init, initOpen, @@ -11,7 +10,6 @@ import { Message, OutMessage, open, - ShowDialog, update, } from "./modal" @@ -23,28 +21,32 @@ describe("modal story", () => { expect(initOpen("rename").isOpen).toBe(true) }) - test("a request to open shows the dialog and emits Opened", () => { + test("a request to open opens the Model and emits Opened; the dialog's Mount shows it", () => { story( update, given(init("rename")), message(Message.RequestedOpen()), expectOutMessage(OutMessage.Opened()), - Command.resolve(ShowDialog, Message.SucceededShowDialog()), + Command.expectNone(), model((next) => expect(next.isOpen).toBe(true)), ) }) - test("a request to close closes the dialog and emits Closed", () => { + test("a request to close closes the Model and emits Closed; removing the dialog releases it", () => { story( update, given(initOpen("rename")), message(Message.RequestedClose()), expectOutMessage(OutMessage.Closed()), - Command.resolve(CloseDialog, Message.CompletedCloseDialog()), + Command.expectNone(), model((next) => expect(next.isOpen).toBe(false)), ) }) + test("closing a closed modal emits nothing", () => { + expect(close(init("rename"))).toEqual({ model: init("rename") }) + }) + test("open and close from the parent match the Messages", () => { expect(open(init("rename")).model.isOpen).toBe(true) expect(close(initOpen("rename")).model.isOpen).toBe(false) diff --git a/apps/web-foldkit/src/ui/modal.ts b/apps/web-foldkit/src/ui/modal.ts index 2f6b0fe2c..5dba53cad 100644 --- a/apps/web-foldkit/src/ui/modal.ts +++ b/apps/web-foldkit/src/ui/modal.ts @@ -1,7 +1,8 @@ import { Dialog } from "@foldkit/ui" import { Schema } from "effect" -import { Submodel } from "foldkit" +import { Submodel, type Update } from "foldkit" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { modifyFields } from "foldkit/struct" import { twMerge } from "tailwind-merge" import { type ModalSize, @@ -46,8 +47,6 @@ export type OutMessage = Dialog.OutMessage /** The Mount on the `<dialog>` that acquires its resources once it renders open. */ export const AcquireResources = Dialog.AcquireResources -export const ShowDialog = Dialog.ShowDialog -export const CloseDialog = Dialog.CloseDialog export const titleId = (id: string): string => Dialog.titleId(init(id)) export const dialogId = (id: string): string => `${id}-content` @@ -56,16 +55,30 @@ export const dialogId = (id: string): string => `${id}-content` export const isCloseRequest = (message: Message): boolean => Schema.is(Message.RequestedClose)(message) /** The dialog rendered open and moved focus in: the moment for a field's autofocus. */ -export const isReady = (message: Message): boolean => - Schema.is(Message.SucceededAcquireResources)(message) +export const isReady = (message: Message): boolean => Schema.is(Message.SucceededAcquireResources)(message) // UPDATE -export const update = Dialog.update -/** Opens the modal from the parent; fold it with `Update.foldChildStep`. */ -export const open = Dialog.open -/** Closes the modal from the parent; fold it with `Update.foldChildStep`. */ -export const close = Dialog.close +type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage> + +/** + * Opens the modal from the parent; fold it with `Update.foldChildStep`. A Model change: the dialog + * renders only while open, and its Mount shows it and takes its resources. + */ +export const open = (model: Model): UpdateReturn => + model.isOpen ? { model } : { model: modifyFields(model, { isOpen: () => true }), outMessage: OutMessage.Opened() } + +/** Closes the modal from the parent; removing the dialog releases its resources and returns focus. */ +export const close = (model: Model): UpdateReturn => + model.isOpen ? { model: modifyFields(model, { isOpen: () => false }), outMessage: OutMessage.Closed() } : { model } + +/** Open and close requests are Model changes (see `open`); the rest is the Dialog's own lifecycle. */ +export const update = (model: Model, message: Message): UpdateReturn => + Schema.is(Message.RequestedOpen)(message) + ? open(model) + : Schema.is(Message.RequestedClose)(message) + ? close(model) + : Dialog.update(model, message) /** A `foldOutMessage` for a parent that reads `isOpen` rather than reacting to Opened or Closed. */ export const ignoreOutMessage = @@ -147,11 +160,16 @@ export const dialogView = <M>( h.div([ ...(isDismissable ? render.backdrop : []), h.Class( - twMerge(parts.backdropClassName, (inputs.isBlurred ?? false) && "backdrop-blur-[1px]"), + twMerge( + parts.backdropClassName, + (inputs.isBlurred ?? false) && "backdrop-blur-[1px]", + ), ), h.DataAttribute("slot", "modal-overlay"), ]), - parts.layoutClassName === null ? panel : h.div([h.Class(parts.layoutClassName)], [panel]), + parts.layoutClassName === null + ? panel + : h.div([h.Class(parts.layoutClassName)], [panel]), ], ) }, @@ -178,5 +196,4 @@ export const controlledModal = <ParentMessage>( model: Model toParentMessage: (message: Message) => ParentMessage }>, -): Html => - dialogView(h, options.model, options, modalParts(options.size ?? "lg"), options.toParentMessage) +): Html => dialogView(h, options.model, options, modalParts(options.size ?? "lg"), options.toParentMessage) diff --git a/apps/web-foldkit/src/ui/sheet.scene.test.ts b/apps/web-foldkit/src/ui/sheet.scene.test.ts index dee2a7ac7..0dcf239a0 100644 --- a/apps/web-foldkit/src/ui/sheet.scene.test.ts +++ b/apps/web-foldkit/src/ui/sheet.scene.test.ts @@ -3,7 +3,7 @@ import * as Scene from "foldkit/scene" import { inertHtml as ih } from "foldkit/html" import { describe, test } from "vitest" import { dialogClose, dialogFooter, dialogHeader } from "./dialog" -import { AcquireResources, CloseDialog, initOpen, Message, titleId, update } from "./modal" +import { AcquireResources, initOpen, Message, titleId, update } from "./modal" import { view } from "./sheet" /** Sheet through the view: a Modal with an edge panel, sharing the Modal Model and update. */ @@ -46,8 +46,7 @@ describe("sheet scene", () => { config, ...opened, Scene.click(Scene.role("button", { name: "Done" })), - Scene.Command.resolve(CloseDialog, Message.CompletedCloseDialog()), - Scene.Mount.expectEnded(AcquireResources), + Scene.Mount.expectEnded(AcquireResources), Scene.expect(Scene.role("dialog")).toBeAbsent(), ) }) diff --git a/apps/web-foldkit/src/ui/sheet.ts b/apps/web-foldkit/src/ui/sheet.ts index e63991c65..de29a4f15 100644 --- a/apps/web-foldkit/src/ui/sheet.ts +++ b/apps/web-foldkit/src/ui/sheet.ts @@ -35,7 +35,9 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view }), closeIconClassName: sheetCloseIconClassName, panelAttributes: childAttributes( - Object.entries(viewInputs.panelAttributes ?? {}).map(([name, value]) => h.Attribute(name, value)), + Object.entries(viewInputs.panelAttributes ?? {}).map(([name, value]) => + h.Attribute(name, value), + ), ), }, (message) => message, From ec72f4e82a6f75752226e3c30941c0a08d7509c5 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:16:51 +0200 Subject: [PATCH 51/52] refactor(web): stateless overlay helpers on Foldkit Anchor and Dom Menus, selects, combo boxes, the date picker, the command menu and the picker popovers position with Anchor (floating-ui), portal with portalToContainingRoot (dialog-aware), and lock scroll / inert the page through Foldkit's Dom. The overlay stack, inert counts and scroll-lock closure in ui/aria/overlay.ts are gone; the topmost overlay is read from the DOM. The vendored React Aria calculatePosition (position.ts) is deleted. The message toolbar anchors with Anchor, and its tooltip and emoji picker render beside each button instead of inside it. The command menu animates in from @starting-style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web-foldkit/src/chat/image-viewer.ts | 2 +- apps/web-foldkit/src/chat/message/toolbar.ts | 112 +++--- .../core/components/ui/command-menu.styles.ts | 12 +- .../src/core/components/ui/popover.styles.ts | 2 +- .../web-foldkit/src/picker-popover/popover.ts | 28 +- apps/web-foldkit/src/ui/aria/overlay.ts | 375 ++++++------------ apps/web-foldkit/src/ui/aria/placement.ts | 5 +- apps/web-foldkit/src/ui/combo-box-view.ts | 2 +- apps/web-foldkit/src/ui/command-menu-view.ts | 62 +-- apps/web-foldkit/src/ui/date-picker.ts | 77 ++-- apps/web-foldkit/src/ui/menu-view.ts | 78 ++-- apps/web-foldkit/src/ui/popover.ts | 13 +- apps/web-foldkit/src/ui/select-view.ts | 49 ++- 13 files changed, 344 insertions(+), 473 deletions(-) diff --git a/apps/web-foldkit/src/chat/image-viewer.ts b/apps/web-foldkit/src/chat/image-viewer.ts index c9fd60868..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<Intent>((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()) } diff --git a/apps/web-foldkit/src/chat/message/toolbar.ts b/apps/web-foldkit/src/chat/message/toolbar.ts index 197029d33..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,7 +6,6 @@ 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" @@ -17,32 +17,6 @@ export const TOP_EMOJIS = ["👍", "❤️", "😂", "🔥", "👀", "🎉"] as 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() @@ -239,25 +219,33 @@ const action = <M>( 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-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/core/components/ui/command-menu.styles.ts b/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts index 694985fb5..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 @@ -14,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]", - "data-entering:fade-in data-entering:animate-in data-entering:duration-300 data-entering:ease-out", - "data-exiting:fade-out data-exiting:animate-out data-exiting:ease-in", + "transition-opacity duration-200 ease-out starting:opacity-0", isBlurred && "backdrop-blur-sm backdrop-filter", ) @@ -25,11 +24,12 @@ 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], - "data-entering:slide-in-from-bottom sm:data-entering:zoom-in-95 sm:data-entering:slide-in-from-bottom-0 data-entering:animate-in data-entering:duration-300 data-entering:ease-out", - "data-exiting:slide-out-to-bottom sm:data-exiting:zoom-out-95 sm:data-exiting:slide-out-to-bottom-0 data-exiting:animate-out data-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" 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 4eb9dc5a2..54d56b234 100644 --- a/apps/web-foldkit/src/core/components/ui/popover.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/popover.styles.ts @@ -3,7 +3,7 @@ * (top|bottom|left|right) and `--button-width`; the panel fades in from `@starting-style` (150ms). */ export const popoverContentBase = [ - "group/popover z-50 min-w-(--button-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)]", + "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 diff --git a/apps/web-foldkit/src/picker-popover/popover.ts b/apps/web-foldkit/src/picker-popover/popover.ts index 34e6cae48..28ab6c810 100644 --- a/apps/web-foldkit/src/picker-popover/popover.ts +++ b/apps/web-foldkit/src/picker-popover/popover.ts @@ -32,22 +32,18 @@ const PortalPickerPopover = Mount.defineStream("PortalPickerPopover", { messages: [PopoverEvent.CompletedPortalPickerPopover, PopoverEvent.PressedOutside], execute: ({ element, id }) => Stream.callback<PortalEvent>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const release = openModalPopover(element, { - triggerId: triggerId(id), - placement: "bottom", - offset: 8, - initialFocusId: dialogId(id), - insideSelector: `#${CSS.escape(popoverId(id))}`, - onInteractOutside: () => Queue.offerUnsafe(queue, PopoverEvent.PressedOutside()), - inertAfterPaint: true, - }) - Queue.offerUnsafe(queue, PopoverEvent.CompletedPortalPickerPopover()) - return release - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + Effect.gen(function* () { + yield* openModalPopover(element, { + triggerId: triggerId(id), + placement: "bottom", + offset: 8, + initialFocusId: dialogId(id), + insideSelector: `#${CSS.escape(popoverId(id))}`, + onInteractOutside: () => Queue.offerUnsafe(queue, PopoverEvent.PressedOutside()), + }) + Queue.offerUnsafe(queue, PopoverEvent.CompletedPortalPickerPopover()) + return yield* Effect.never + }), ), }) diff --git a/apps/web-foldkit/src/ui/aria/overlay.ts b/apps/web-foldkit/src/ui/aria/overlay.ts index 36d264d7d..3565e0213 100644 --- a/apps/web-foldkit/src/ui/aria/overlay.ts +++ b/apps/web-foldkit/src/ui/aria/overlay.ts @@ -1,208 +1,81 @@ +import { Anchor } from "@foldkit/ui" +import { Effect, type Scope } from "effect" +import * as Dom from "foldkit/dom" import type { Html, HtmlBuilder } from "foldkit/html" -import { calculatePosition, type Placement } from "./position" +import { type Placement, toAnchorPlacement } from "./placement" /** - * Imperative overlay plumbing shared by the overlay primitives (menu, select, popover, tooltip, - * dialog, sheet). Each helper mirrors one React Aria hook and returns its cleanup, so a - * primitive's Mount can pair setup and release with `Effect.acquireRelease`. + * Overlay plumbing the menu, select, combo box, date picker, command menu and picker popovers share. + * Each helper returns its cleanup (or is a scoped Effect), so a primitive's Mount pairs setup and + * release. Positioning, portals, scroll locks and inert isolation come from Foldkit, which keeps + * their reference counts; the helpers here hold no state of their own. */ -// POSITIONING (useOverlayPosition) +// POSITIONING export interface PositionConfig { readonly triggerId: string readonly placement: Placement + /** The gap along the main axis. */ readonly offset: number readonly crossOffset?: number - readonly shouldFlip?: boolean - /** `--trigger-width`, set by MenuTrigger and Select from the trigger's `offsetWidth`. */ + /** `--trigger-width`, set by MenuTrigger and Select from the trigger's width. */ readonly isTriggerWidthSet?: boolean - /** The arrow wrapper (OverlayArrow), positioned along the cross axis. */ - readonly arrowSelector?: string - /** Defaults to 12, React Aria's `containerPadding`. */ - readonly containerPadding?: number } -/** The overlay positions in LTR, so logical `start`/`end` resolve to `left`/`right`. */ -const logicalPlacements: Partial<Record<Placement, Placement>> = { - start: "left", - "start top": "left top", - "start bottom": "left bottom", - end: "right", - "end top": "right top", - "end bottom": "right bottom", - "top start": "top left", - "top end": "top right", - "bottom start": "bottom left", - "bottom end": "bottom right", -} -const physicalPlacement = (placement: Placement): Placement => logicalPlacements[placement] ?? placement - -const CONTAINER_PADDING = 12 +const VIEWPORT_PADDING = 12 const OVERLAY_Z_INDEX = "100000" -/** Positions `overlay` against the trigger exactly as React Aria's `useOverlayPosition` does. */ +/** + * Anchors `overlay` to the trigger with Floating UI: it flips and shifts inside the viewport, caps + * its height to the space left, and publishes `--button-width` and `data-placement`. + */ export const positionOverlay = (overlay: HTMLElement, config: PositionConfig): (() => void) => { - const trigger = document.getElementById(config.triggerId) - if (trigger === null) return () => undefined - const arrow = config.arrowSelector ? overlay.querySelector<HTMLElement>(config.arrowSelector) : null - overlay.style.position = "absolute" overlay.style.zIndex = OVERLAY_Z_INDEX - if (config.isTriggerWidthSet) overlay.style.setProperty("--trigger-width", `${trigger.offsetWidth}px`) - - const update = () => { - overlay.style.top = "0px" - overlay.style.bottom = "" - overlay.style.maxHeight = `${window.visualViewport?.height ?? window.innerHeight}px` - const result = calculatePosition({ - placement: physicalPlacement(config.placement), - overlayNode: overlay, - targetNode: trigger, - scrollNode: overlay, - padding: config.containerPadding ?? CONTAINER_PADDING, - shouldFlip: config.shouldFlip ?? true, - boundaryElement: document.body, - offset: config.offset, - crossOffset: config.crossOffset ?? 0, - maxHeight: undefined, - arrowSize: arrow ? arrow.offsetWidth : 0, - arrowBoundaryOffset: 0, - }) - overlay.style.top = "" - overlay.style.bottom = "" - overlay.style.left = "" - overlay.style.right = "" - for (const [key, value] of Object.entries(result.position)) - overlay.style.setProperty(key, `${value}px`) - overlay.style.maxHeight = result.maxHeight != null ? `${result.maxHeight}px` : "" - overlay.style.setProperty( - "--trigger-anchor-point", - `${result.triggerAnchorPoint.x}px ${result.triggerAnchorPoint.y}px`, - ) - overlay.setAttribute("data-placement", result.placement) - if (arrow) { - arrow.setAttribute("data-placement", result.placement) - arrow.style.position = "absolute" - arrow.style.transform = - result.placement === "top" || result.placement === "bottom" - ? "translateX(-50%)" - : "translateY(-50%)" - for (const side of ["top", "bottom", "left", "right"]) arrow.style.removeProperty(side) - arrow.style.setProperty(result.placement, "100%") - if (result.arrowOffsetLeft != null) arrow.style.left = `${result.arrowOffsetLeft}px` - if (result.arrowOffsetTop != null) arrow.style.top = `${result.arrowOffsetTop}px` - } - } - - update() - const observer = new ResizeObserver(update) - observer.observe(overlay) - observer.observe(trigger) - window.addEventListener("resize", update, false) - return () => { - observer.disconnect() - window.removeEventListener("resize", update, false) - } + const trigger = document.getElementById(config.triggerId) + if (config.isTriggerWidthSet && trigger !== null) + overlay.style.setProperty("--trigger-width", `${trigger.offsetWidth}px`) + return Anchor.anchorSetup(overlay, { + buttonId: config.triggerId, + anchor: { + placement: toAnchorPlacement(config.placement), + gap: config.offset, + offset: config.crossOffset ?? 0, + padding: VIEWPORT_PADDING, + portal: false, + }, + interceptTab: false, + }) } -// PORTAL AND MODALITY (Overlay, ariaHideOutside, usePreventScroll) +// PORTAL AND ISOLATION /** - * Moves `element` to the end of `<body>` like React Aria's portal. A modal overlay also makes the - * other body children inert and locks page scroll; non-modal ones (submenus, tooltips) only move. + * Moves `element` out of its ancestors' clipping: to Foldkit's portal root, or into the `<dialog>` + * it renders in, so an overlay opened from a modal stays above the modal and interactive. */ -export const portalOverlay = ( - element: Element, - options: { readonly isModal: boolean; readonly inertAfterPaint?: boolean }, -): (() => void) => { - document.body.appendChild(element) - const releaseInert = options.isModal - ? hideOutside(element, options.inertAfterPaint ?? false) - : () => undefined - const releaseScroll = options.isModal ? preventScroll() : () => undefined - const releaseStack = options.isModal ? pushOverlay() : () => undefined - return () => { - releaseStack() - releaseInert() - releaseScroll() - element.remove() - } -} +export const portalOverlay = (element: Element): (() => void) => Anchor.portalToContainingRoot(element) -/** React Aria's `visibleOverlays`: only the topmost modal overlay reacts to outside presses. */ -const overlayStack: Array<symbol> = [] - -const pushOverlay = (): (() => void) & { readonly isTopmost: () => boolean } => { - const token = Symbol("overlay") - overlayStack.push(token) - const release = () => { - const index = overlayStack.lastIndexOf(token) - if (index !== -1) overlayStack.splice(index, 1) - } - return Object.assign(release, { isTopmost: () => overlayStack[overlayStack.length - 1] === token }) -} - -const inertCounts = new WeakMap<Element, number>() - -/** React Aria keeps its live announcer and top-layer nodes reachable while an overlay hides the rest. */ -const staysVisible = (element: Element) => - element.tagName === "SCRIPT" || - element.hasAttribute("data-live-announcer") || - element.hasAttribute("data-react-aria-top-layer") +const OVERLAY_ROOT_ATTRIBUTE = "data-overlay-root" /** - * `inert` restyles the whole app (about 2,200 elements, 8 to 10ms). With `afterPaint` it lands in - * the next frame, so the overlay paints in the frame that inserted it. Only for overlays whose own - * hit-testable underlay takes the pointer at once; otherwise the hover leave would be missed. + * A modal overlay: page scroll locks and everything outside `root` turns inert (after the next + * commit, so the overlay paints first). `key` names the overlay; `root` is marked with it, so + * outside presses can tell the topmost modal overlay. */ -const hideOutside = (visible: Element, afterPaint: boolean): (() => void) => { - const hidden = [...document.body.children].filter( - (child) => child !== visible && !child.contains(visible) && !staysVisible(child), - ) - let isApplied = false - const apply = () => { - isApplied = true - for (const element of hidden) { - const count = inertCounts.get(element) ?? 0 - if (count === 0) element.setAttribute("inert", "") - inertCounts.set(element, count + 1) - } - } - const frame = afterPaint ? requestAnimationFrame(apply) : null - if (frame === null) apply() - return () => { - if (frame !== null) cancelAnimationFrame(frame) - if (!isApplied) return - for (const element of hidden) { - const count = (inertCounts.get(element) ?? 1) - 1 - inertCounts.set(element, count) - if (count === 0) element.removeAttribute("inert") - } - } -} - -let scrollLocks = 0 -let restoreScroll = () => undefined as void - -const preventScroll = (): (() => void) => { - if (scrollLocks++ === 0) { - const root = document.documentElement - const scrollbarWidth = window.innerWidth - root.clientWidth - const previousOverflow = root.style.overflow - const previousPadding = root.style.paddingRight - if (scrollbarWidth > 0) root.style.paddingRight = `${scrollbarWidth}px` - root.style.overflow = "hidden" - restoreScroll = () => { - root.style.overflow = previousOverflow - root.style.paddingRight = previousPadding - if (root.getAttribute("style") === "") root.removeAttribute("style") - } - } - return () => { - if (--scrollLocks === 0) restoreScroll() - } -} +export const isolateOverlay = (root: Element, key: string): Effect.Effect<void, never, Scope.Scope> => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => root.setAttribute(OVERLAY_ROOT_ATTRIBUTE, key)), + () => Effect.sync(() => root.removeAttribute(OVERLAY_ROOT_ATTRIBUTE)), + ) + yield* Effect.acquireRelease(Dom.lockScroll, () => Dom.unlockScroll) + yield* Effect.acquireRelease( + Dom.inertOthers(key, [`[${OVERLAY_ROOT_ATTRIBUTE}="${CSS.escape(key)}"]`]), + () => Dom.restoreInert(key), + ) + }) /** * ariaHideOutside as useComboBox uses it: every element outside `visible` gets aria-hidden, so @@ -212,7 +85,7 @@ export const ariaHideOutside = (visible: ReadonlyArray<Element>): (() => void) = const hidden: Array<Element> = [] const walk = (parent: Element) => { for (const child of parent.children) { - if (staysVisible(child) || visible.includes(child)) continue + if (visible.includes(child)) continue if (visible.some((element) => child.contains(element))) walk(child) else if (child.getAttribute("aria-hidden") !== "true") { child.setAttribute("aria-hidden", "true") @@ -228,17 +101,27 @@ export const ariaHideOutside = (visible: ReadonlyArray<Element>): (() => void) = // DISMISS (useInteractOutside) +/** The overlay opened last is the one an outside press dismisses. */ +const isTopmost = (root: Element) => { + const roots = document.querySelectorAll(`[${OVERLAY_ROOT_ATTRIBUTE}]`) + return roots.length === 0 || roots[roots.length - 1] === root +} + /** * Calls `onInteractOutside` on a pointer press that starts and ends outside every element matching - * `insideSelector`, like React Aria. The pointerdown is prevented, so the press moves no focus. + * `insideSelector`, while `root` is the topmost modal overlay. The pointerdown is prevented, so the + * press moves no focus. */ -export const watchInteractOutside = (insideSelector: string, onInteractOutside: () => void): (() => void) => { +export const watchInteractOutside = ( + root: Element, + insideSelector: string, + onInteractOutside: () => void, +): (() => void) => { let isPressStartedOutside = false - const entry = pushOverlay() const isOutside = (event: Event) => event.target instanceof Element ? event.target.closest(insideSelector) === null : true const onPointerDown = (event: PointerEvent) => { - isPressStartedOutside = event.button === 0 && entry.isTopmost() && isOutside(event) + isPressStartedOutside = event.button === 0 && isTopmost(root) && isOutside(event) if (isPressStartedOutside) event.preventDefault() } const onPointerUp = (event: PointerEvent) => { @@ -248,13 +131,12 @@ export const watchInteractOutside = (insideSelector: string, onInteractOutside: document.addEventListener("pointerdown", onPointerDown, true) document.addEventListener("pointerup", onPointerUp, true) return () => { - entry() document.removeEventListener("pointerdown", onPointerDown, true) document.removeEventListener("pointerup", onPointerUp, true) } } -// FOCUS (FocusScope restoreFocus) +// FOCUS (FocusScope restoreFocus and contain) /** Focuses the trigger again when an overlay that holds focus goes away (FocusScope `restoreFocus`). */ export const restoreFocusTo = @@ -265,8 +147,18 @@ export const restoreFocusTo = document.getElementById(triggerId)?.focus({ preventScroll: true }) } +/** FocusScope `restoreFocus` for overlays opened without a trigger: back to whatever had focus. */ +export const restoreFocusToPrevious = (overlay: Element): (() => void) => { + const previous = document.activeElement + return () => { + const active = document.activeElement + if (active !== null && active !== document.body && !overlay.contains(active)) return + if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) + } +} + /** FocusScope `contain`: Tab and Shift+Tab cycle through the tabbable elements inside `root`. */ -export const containFocus = (root: HTMLElement | Element): (() => void) => { +export const containFocus = (root: Element): (() => void) => { const selector = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]" const onKeyDown = (event: Event) => { @@ -328,50 +220,12 @@ export const containFocus = (root: HTMLElement | Element): (() => void) => { } } -// MODAL OVERLAY (useViewportSize, DialogHeader/DialogFooter resize observers) - -/** ModalOverlay's `--visual-viewport-height` and `--page-height`. */ -export const trackViewportHeight = (overlay: HTMLElement): (() => void) => { - const update = () => { - const scrolling = document.scrollingElement ?? document.documentElement - const pageHeight = scrolling.scrollHeight - (scrolling.getBoundingClientRect().height % 1) - overlay.style.setProperty( - "--visual-viewport-height", - `${window.visualViewport?.height ?? window.innerHeight}px`, - ) - overlay.style.setProperty("--page-height", `${pageHeight}px`) - } - update() - window.visualViewport?.addEventListener("resize", update) - return () => window.visualViewport?.removeEventListener("resize", update) -} - -/** DialogHeader and DialogFooter publish their heights on the dialog, which DialogBody's max height uses. */ -export const observeDialogParts = (root: Element): (() => void) => { - const parts = [ - ["dialog-header", "--dialog-header-height"], - ["dialog-footer", "--dialog-footer-height"], - ] as const - const observers = parts.flatMap(([slot, property]) => { - const part = root.querySelector<HTMLElement>(`[data-slot="${slot}"]`) - if (part === null) return [] - const observer = new ResizeObserver(() => - part.parentElement?.style.setProperty(property, `${part.clientHeight}px`), - ) - observer.observe(part) - return [observer] - }) - return () => { - for (const observer of observers) observer.disconnect() - } -} - // MODAL POPOVER (Popover with an underlay, as MenuTrigger, Select and DialogTrigger render it) /** - * Everything a modal popover does on mount: portal `root` to the body with the rest inert and - * scroll locked, position its `[data-popover]` panel, move focus in, dismiss on outside press, - * and give focus back to the trigger on release. + * Everything a modal popover does while mounted: portal `root`, isolate the page, anchor its + * `[data-popover]` panel, move focus in, dismiss on an outside press, and give focus back to the + * trigger on release. */ export const openModalPopover = ( root: Element, @@ -380,52 +234,51 @@ export const openModalPopover = ( initialFocusId: string insideSelector: string onInteractOutside: () => void - inertAfterPaint?: boolean }>, -): (() => void) => { - const restoreFocus = restoreFocusTo(config.triggerId, root) - const releasePortal = portalOverlay(root, { - isModal: true, - inertAfterPaint: config.inertAfterPaint ?? false, +): Effect.Effect<void, never, Scope.Scope> => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const restoreFocus = restoreFocusTo(config.triggerId, root) + const releasePortal = portalOverlay(root) + return () => { + releasePortal() + restoreFocus() + } + }), + (release) => Effect.sync(release), + ) + yield* isolateOverlay(root, config.insideSelector) + yield* Effect.acquireRelease( + Effect.sync(() => { + const popover = root.querySelector<HTMLElement>("[data-popover]") + const releasePosition = popover ? positionOverlay(popover, config) : () => undefined + document.getElementById(config.initialFocusId)?.focus({ preventScroll: true }) + const releaseOutside = watchInteractOutside( + root, + config.insideSelector, + config.onInteractOutside, + ) + return () => { + releaseOutside() + releasePosition() + } + }), + (release) => Effect.sync(release), + ) }) - const popover = root.querySelector<HTMLElement>("[data-popover]") - const releasePosition = popover ? positionOverlay(popover, config) : () => undefined - document.getElementById(config.initialFocusId)?.focus({ preventScroll: true }) - const releaseOutside = watchInteractOutside(config.insideSelector, config.onInteractOutside) - return () => { - releaseOutside() - releasePosition() - releasePortal() - restoreFocus() - } -} - -/** FocusScope `restoreFocus` for overlays opened without a trigger: back to whatever had focus. */ -export const restoreFocusToPrevious = (overlay: Element): (() => void) => { - const previous = document.activeElement - return () => { - const active = document.activeElement - if (active !== null && active !== document.body && !overlay.contains(active)) return - if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) - } -} // MARKUP /** React Aria's visually hidden `DismissButton`, rendered at both ends of a modal popover. */ export const dismissButton = <Message>(h: HtmlBuilder<Message>, onDismiss: Message): Html => h.div( - [ - h.Attribute( - "style", - "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;", - ), - ], + [h.Class("sr-only")], [ h.button([ h.Attribute("aria-label", "Dismiss"), h.Attribute("tabindex", "-1"), - h.Attribute("style", "width: 1px; height: 1px;"), + h.Type("button"), h.OnClick(onDismiss), ]), ], @@ -437,4 +290,4 @@ export const focusScopeSentinel = <Message>(h: HtmlBuilder<Message>, edge: "star /** The fixed underlay a modal popover renders behind itself. */ export const popoverUnderlay = <Message>(h: HtmlBuilder<Message>): Html => - h.div([h.Attribute("data-testid", "underlay"), h.Attribute("style", "position: fixed; inset: 0px;")]) + h.div([h.Attribute("data-testid", "underlay"), h.Class("fixed inset-0")]) diff --git a/apps/web-foldkit/src/ui/aria/placement.ts b/apps/web-foldkit/src/ui/aria/placement.ts index cbace69fb..535716d99 100644 --- a/apps/web-foldkit/src/ui/aria/placement.ts +++ b/apps/web-foldkit/src/ui/aria/placement.ts @@ -1,8 +1,7 @@ import type { Placement as AnchorPlacement } from "@foldkit/ui/anchor" import { Schema } from "effect" -import type { Placement as PositionPlacement } from "./position" -/** React Aria's `Placement` as a Schema, so Mount args and Models carry it without a cast. */ +/** The overlay placements (React Aria's names) as a Schema, so Mount args and Models carry them. */ const placements = [ "bottom", "bottom left", @@ -26,7 +25,7 @@ const placements = [ "end", "end top", "end bottom", -] as const satisfies ReadonlyArray<PositionPlacement> +] as const export const Placement = Schema.Literals(placements) export type Placement = typeof Placement.Type diff --git a/apps/web-foldkit/src/ui/combo-box-view.ts b/apps/web-foldkit/src/ui/combo-box-view.ts index 98ebdf624..605ec5589 100644 --- a/apps/web-foldkit/src/ui/combo-box-view.ts +++ b/apps/web-foldkit/src/ui/combo-box-view.ts @@ -53,7 +53,7 @@ const PortalComboBox = Mount.defineStream("PortalComboBox", { Stream.callback<PortalComboBoxMessage>((queue) => Effect.acquireRelease( Effect.sync(() => { - const releasePortal = portalOverlay(element, { isModal: false }) + const releasePortal = portalOverlay(element) const popover = element.querySelector<HTMLElement>("[data-popover]") const input = document.getElementById(inputId(id)) const releasePosition = popover diff --git a/apps/web-foldkit/src/ui/command-menu-view.ts b/apps/web-foldkit/src/ui/command-menu-view.ts index c32675106..0cc084848 100644 --- a/apps/web-foldkit/src/ui/command-menu-view.ts +++ b/apps/web-foldkit/src/ui/command-menu-view.ts @@ -33,7 +33,7 @@ import { focusScopeSentinel, portalOverlay, restoreFocusToPrevious, - trackViewportHeight, + isolateOverlay, watchInteractOutside, } from "./aria/overlay" import { @@ -57,32 +57,40 @@ export const PortalCommandMenu = Mount.defineStream("PortalCommandMenu", { messages: [Message.CompletedPortalCommandMenu, Message.PressedOutside], execute: ({ element, id, isDismissable }) => Stream.callback<PortalCommandMenuMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const restoreFocus = restoreFocusToPrevious(element) - const releasePortal = portalOverlay(element, { isModal: true, inertAfterPaint: true }) - const overlay = element.querySelector<HTMLElement>("[data-modal-overlay]") - const releaseViewport = overlay ? trackViewportHeight(overlay) : () => undefined - ;(document.getElementById(searchId(id)) ?? document.getElementById(dialogId(id)))?.focus({ - preventScroll: true, - }) - const releaseFocus = containFocus(element) - const releaseOutside = isDismissable - ? watchInteractOutside(`[data-modal-content="${CSS.escape(id)}"]`, () => - Queue.offerUnsafe(queue, Message.PressedOutside()), - ) - : () => undefined - Queue.offerUnsafe(queue, Message.CompletedPortalCommandMenu()) - return () => { - releaseOutside() - releaseFocus() - releaseViewport() - releasePortal() - restoreFocus() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + Effect.gen(function* () { + const restoreFocus = restoreFocusToPrevious(element) + yield* Effect.acquireRelease( + Effect.sync(() => portalOverlay(element)), + (releasePortal) => + Effect.sync(() => { + releasePortal() + restoreFocus() + }), + ) + yield* isolateOverlay(element, `command-menu-${id}`) + yield* Effect.acquireRelease( + Effect.sync(() => { + ;( + document.getElementById(searchId(id)) ?? document.getElementById(dialogId(id)) + )?.focus({ + preventScroll: true, + }) + const releaseFocus = containFocus(element) + const releaseOutside = isDismissable + ? watchInteractOutside(element, `[data-modal-content="${CSS.escape(id)}"]`, () => + Queue.offerUnsafe(queue, Message.PressedOutside()), + ) + : () => undefined + Queue.offerUnsafe(queue, Message.CompletedPortalCommandMenu()) + return () => { + releaseOutside() + releaseFocus() + } + }), + (release) => Effect.sync(release), + ) + return yield* Effect.never + }), ), }) diff --git a/apps/web-foldkit/src/ui/date-picker.ts b/apps/web-foldkit/src/ui/date-picker.ts index 34d1127d5..6bf802448 100644 --- a/apps/web-foldkit/src/ui/date-picker.ts +++ b/apps/web-foldkit/src/ui/date-picker.ts @@ -2,7 +2,13 @@ import { Effect, Option, Queue, Schema, Stream } from "effect" import { Command, Mount, Subscription, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" -import { portalOverlay, positionOverlay, restoreFocusTo, watchInteractOutside } from "./aria/overlay" +import { + isolateOverlay, + portalOverlay, + positionOverlay, + restoreFocusTo, + watchInteractOutside, +} from "./aria/overlay" import * as Calendar from "./calendar" import * as D from "./calendar-date" import * as Segments from "./date-segments" @@ -82,35 +88,46 @@ export const PortalPicker = Mount.defineStream("PortalDatePicker", { messages: [Message.CompletedPortalPicker, Message.PressedOutside], execute: ({ element, id }) => Stream.callback<PortalMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const restoreFocus = restoreFocusTo(triggerId(id), element) - const releasePortal = portalOverlay(element, { isModal: true }) - const popover = element.querySelector<HTMLElement>("[data-popover]") - const releasePosition = popover - ? positionOverlay(popover, { - triggerId: groupId(id), - placement: "bottom", - offset: 8, - isTriggerWidthSet: true, - }) - : () => undefined - element - .querySelector<HTMLElement>('[role=grid] [role=button][tabindex="0"]') - ?.focus({ preventScroll: true }) - const releaseOutside = watchInteractOutside(`#${CSS.escape(popoverId(id))}`, () => - Queue.offerUnsafe(queue, Message.PressedOutside()), - ) - Queue.offerUnsafe(queue, Message.CompletedPortalPicker()) - return () => { - releaseOutside() - releasePosition() - releasePortal() - restoreFocus() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + Effect.gen(function* () { + const restoreFocus = restoreFocusTo(triggerId(id), element) + yield* Effect.acquireRelease( + Effect.sync(() => portalOverlay(element)), + (releasePortal) => + Effect.sync(() => { + releasePortal() + restoreFocus() + }), + ) + yield* isolateOverlay(element, popoverId(id)) + yield* Effect.acquireRelease( + Effect.sync(() => { + const popover = element.querySelector<HTMLElement>("[data-popover]") + const releasePosition = popover + ? positionOverlay(popover, { + triggerId: groupId(id), + placement: "bottom", + offset: 8, + isTriggerWidthSet: true, + }) + : () => undefined + element + .querySelector<HTMLElement>('[role=grid] [role=button][tabindex="0"]') + ?.focus({ preventScroll: true }) + const releaseOutside = watchInteractOutside( + element, + `#${CSS.escape(popoverId(id))}`, + () => Queue.offerUnsafe(queue, Message.PressedOutside()), + ) + Queue.offerUnsafe(queue, Message.CompletedPortalPicker()) + return () => { + releaseOutside() + releasePosition() + } + }), + (release) => Effect.sync(release), + ) + return yield* Effect.never + }), ), }) diff --git a/apps/web-foldkit/src/ui/menu-view.ts b/apps/web-foldkit/src/ui/menu-view.ts index f52490ca1..f89d44633 100644 --- a/apps/web-foldkit/src/ui/menu-view.ts +++ b/apps/web-foldkit/src/ui/menu-view.ts @@ -65,42 +65,43 @@ const PortalMenu = Mount.defineStream("PortalMenu", { messages: [Message.CompletedPortalMenu, Message.PressedOutside], execute: ({ element, id, placement, offset, crossOffset, isTriggerWidthSet, initialFocusId }) => Stream.callback<PortalMenuMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const release = openModalPopover(element, { - triggerId: triggerId(id), - placement, - offset, - crossOffset, - isTriggerWidthSet, - initialFocusId, - insideSelector: popoverSelector(id), - onInteractOutside: () => Queue.offerUnsafe(queue, Message.PressedOutside()), - }) - // useMenuItem focuses an item in onHoverStart, synchronously, so the click that follows - // lands on a focused item and never focuses it with the mouse (Chrome keeps :focus-visible). - const onPointerOver = (event: Event) => { - if (!(event instanceof PointerEvent) || event.pointerType === "touch") return - const item = - event.target instanceof Element - ? event.target.closest('[role^="menuitem"]') - : null - if ( - item instanceof HTMLElement && - item.getAttribute("aria-disabled") !== "true" && - document.activeElement !== item - ) - item.focus({ preventScroll: true }) - } - element.addEventListener("pointerover", onPointerOver) - Queue.offerUnsafe(queue, Message.CompletedPortalMenu()) - return () => { - element.removeEventListener("pointerover", onPointerOver) - release() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + openModalPopover(element, { + triggerId: triggerId(id), + placement, + offset, + crossOffset, + isTriggerWidthSet, + initialFocusId, + insideSelector: popoverSelector(id), + onInteractOutside: () => Queue.offerUnsafe(queue, Message.PressedOutside()), + }).pipe( + Effect.andThen( + Effect.acquireRelease( + Effect.sync(() => { + // useMenuItem focuses an item in onHoverStart, synchronously, so the click that follows + // lands on a focused item and never focuses it with the mouse (Chrome keeps :focus-visible). + const onPointerOver = (event: Event) => { + if (!(event instanceof PointerEvent) || event.pointerType === "touch") return + const item = + event.target instanceof Element + ? event.target.closest('[role^="menuitem"]') + : null + if ( + item instanceof HTMLElement && + item.getAttribute("aria-disabled") !== "true" && + document.activeElement !== item + ) + item.focus({ preventScroll: true }) + } + element.addEventListener("pointerover", onPointerOver) + Queue.offerUnsafe(queue, Message.CompletedPortalMenu()) + return () => element.removeEventListener("pointerover", onPointerOver) + }), + (release) => Effect.sync(release), + ), + ), + Effect.andThen(Effect.never), + ), ), }) @@ -385,7 +386,10 @@ const submenuPopover = ( triggerKey: string, focusedKey: Option.Option<string>, ): Html => { - const trigger = Array.findFirst(rootItems(viewInputs.entries), (candidate) => candidate.key === triggerKey) + const trigger = Array.findFirst( + rootItems(viewInputs.entries), + (candidate) => candidate.key === triggerKey, + ) const leaves = Option.match(trigger, { onNone: () => [], onSome: (found) => found.submenu }) return h.div( [ diff --git a/apps/web-foldkit/src/ui/popover.ts b/apps/web-foldkit/src/ui/popover.ts index 75632043c..243b4d204 100644 --- a/apps/web-foldkit/src/ui/popover.ts +++ b/apps/web-foldkit/src/ui/popover.ts @@ -85,7 +85,10 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view }, focusSelector: `#${contentId(model.id)}`, toView: (render: Popover.RenderInfo) => { - const closeAttributes = childAttributes([h.Type("button"), h.OnClick(Message.RequestedClose())]) + const closeAttributes = childAttributes([ + h.Type("button"), + h.OnClick(Message.RequestedClose()), + ]) return h.div( [h.Class("contents")], [ @@ -96,7 +99,13 @@ export const view = Submodel.defineView<Model, Message, ViewInputs>((model, view h.div( [ ...render.panel, - h.Class(twMerge(...popoverContentBase, viewInputs.className)), + h.Class( + twMerge( + ...popoverContentBase, + "min-w-(--button-width)", + viewInputs.className, + ), + ), h.Role("dialog"), h.AriaLabelledBy(triggerId(model.id)), ], diff --git a/apps/web-foldkit/src/ui/select-view.ts b/apps/web-foldkit/src/ui/select-view.ts index e6c5f2705..5c0fb62d1 100644 --- a/apps/web-foldkit/src/ui/select-view.ts +++ b/apps/web-foldkit/src/ui/select-view.ts @@ -42,32 +42,29 @@ const PortalSelect = Mount.defineStream("PortalSelect", { messages: [Message.CompletedPortalSelect, Message.PressedOutside], execute: ({ element, id, initialFocusId }) => Stream.callback<PortalSelectMessage>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const release = openModalPopover(element, { - triggerId: triggerId(id), - placement: "bottom", - offset: SELECT_OFFSET, - isTriggerWidthSet: true, - initialFocusId, - insideSelector: `[data-select-popover="${CSS.escape(id)}"]`, - onInteractOutside: () => Queue.offerUnsafe(queue, Message.PressedOutside()), - }) - // usePress on an option prevents the mousedown, so the click moves no focus and - // the option keeps the `:focus-visible` its hover focus inherited. - const onMouseDown = (event: Event) => { - if (event.target instanceof Element && event.target.closest('[role="option"]')) - event.preventDefault() - } - element.addEventListener("mousedown", onMouseDown) - Queue.offerUnsafe(queue, Message.CompletedPortalSelect()) - return () => { - element.removeEventListener("mousedown", onMouseDown) - release() - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + Effect.gen(function* () { + yield* openModalPopover(element, { + triggerId: triggerId(id), + placement: "bottom", + offset: SELECT_OFFSET, + isTriggerWidthSet: true, + initialFocusId, + insideSelector: `[data-select-popover="${CSS.escape(id)}"]`, + onInteractOutside: () => Queue.offerUnsafe(queue, Message.PressedOutside()), + }) + // usePress on an option prevents the mousedown, so the click moves no focus and + // the option keeps the `:focus-visible` its hover focus inherited. + const onMouseDown = (event: Event) => { + if (event.target instanceof Element && event.target.closest('[role="option"]')) + event.preventDefault() + } + yield* Effect.acquireRelease( + Effect.sync(() => element.addEventListener("mousedown", onMouseDown)), + () => Effect.sync(() => element.removeEventListener("mousedown", onMouseDown)), + ) + Queue.offerUnsafe(queue, Message.CompletedPortalSelect()) + return yield* Effect.never + }), ), }) From d620f82f9d7d666ec00ed55503e706246cdef769 Mon Sep 17 00:00:00 2001 From: Makisuo <minetoaster12@gmail.com> Date: Fri, 16 Oct 2026 00:16:51 +0200 Subject: [PATCH 52/52] refactor(web): pending-button announcer as an app Resource; no ui/aria module state pending-announcement no longer installs a document observer at import time or writes react-aria-button-N ids: it is a scoped Layer in the app's resources that announces the button's label. The live announcer finds its node in the DOM instead of a module variable. With no module state left in ui/aria, the oxlint override for it is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .oxlintrc.json | 8 --- apps/web-foldkit/src/app/view.ts | 2 +- apps/web-foldkit/src/entry.ts | 3 +- apps/web-foldkit/src/ui/aria/announcer.ts | 17 +++-- .../src/ui/aria/pending-announcement.ts | 65 ++++++++++--------- apps/web-foldkit/src/ui/toast-view.ts | 1 - 6 files changed, 49 insertions(+), 47 deletions(-) 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/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index a66f18a08..8a633ec47 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -33,7 +33,7 @@ import { mySettingsSidebar, notificationsSidebar, settingsSidebar } from "../she import type * as Menu from "../ui/menu" import type * as ModalKit from "../ui/modal" import { Message } from "./message" -import { type Model, OrgState, resolvedThemeOf, Session, 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. diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts index c46d04299..54ff621aa 100644 --- a/apps/web-foldkit/src/entry.ts +++ b/apps/web-foldkit/src/entry.ts @@ -11,6 +11,7 @@ import { desktopSessionBearer } from "./platform/desktop-auth" import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" import { NotificationSinks } from "./app/notification-sinks" import { ResourcesLive } from "./rpc" +import * as PendingAnnouncement from "./ui/aria/pending-announcement" const container = document.getElementById("app")! @@ -24,7 +25,7 @@ const startApplication = () => view, subscriptions, // The sinks own audio and native notifications, so they are built here, in the browser only. - resources: Layer.merge(ResourcesLive, NotificationSinks.layer), + resources: Layer.mergeAll(ResourcesLive, NotificationSinks.layer, PendingAnnouncement.layer), container, routing: { onUrlRequest: (request) => Message.ClickedLink({ request }), diff --git a/apps/web-foldkit/src/ui/aria/announcer.ts b/apps/web-foldkit/src/ui/aria/announcer.ts index 9cca17ec4..004cd0445 100644 --- a/apps/web-foldkit/src/ui/aria/announcer.ts +++ b/apps/web-foldkit/src/ui/aria/announcer.ts @@ -13,8 +13,6 @@ interface Announcer { readonly politeLog: HTMLElement } -let announcer: Announcer | undefined - const createLog = (ariaLive: string) => { const log = document.createElement("div") log.setAttribute("role", "log") @@ -23,6 +21,14 @@ const createLog = (ariaLive: string) => { return log } +/** The announcer lives in the DOM: an existing `[data-live-announcer]` node is reused. */ +const existingAnnouncer = (): Announcer | undefined => { + const node = document.querySelector<HTMLElement>("[data-live-announcer]") + const assertiveLog = node?.querySelector<HTMLElement>('[aria-live="assertive"]') + const politeLog = node?.querySelector<HTMLElement>('[aria-live="polite"]') + return node && assertiveLog && politeLog ? { node, assertiveLog, politeLog } : undefined +} + const createAnnouncer = (): Announcer => { const node = document.createElement("div") node.dataset.liveAnnouncer = "true" @@ -68,9 +74,9 @@ export const announce = ( timeout: number = LIVE_REGION_TIMEOUT, assertiveness: Assertiveness = "assertive", ): void => { - if (announcer !== undefined) return append(announcer, message, timeout, assertiveness) + const existing = existingAnnouncer() + if (existing !== undefined) return append(existing, message, timeout, assertiveness) const created = createAnnouncer() - announcer = created setTimeout(() => { if (created.node.isConnected) append(created, message, timeout, assertiveness) }, FIRST_ANNOUNCEMENT_DELAY) @@ -78,7 +84,8 @@ export const announce = ( /** clearAnnouncer("assertive"): drops every message still in the assertive log. */ export const clearAssertive = (): void => { - if (announcer !== undefined) announcer.assertiveLog.innerHTML = "" + const existing = existingAnnouncer() + if (existing !== undefined) existing.assertiveLog.innerHTML = "" } export type Selection = ReadonlyArray<string> | "all" diff --git a/apps/web-foldkit/src/ui/aria/pending-announcement.ts b/apps/web-foldkit/src/ui/aria/pending-announcement.ts index 18bd02732..0b52f1c77 100644 --- a/apps/web-foldkit/src/ui/aria/pending-announcement.ts +++ b/apps/web-foldkit/src/ui/aria/pending-announcement.ts @@ -1,58 +1,61 @@ +import { Effect, Layer } from "effect" import { announce } from "./announcer" /** - * RAC Button announces a focused button whose pending state flips, as an `img` labelled by the - * button. One document observer follows `data-pending`, so buttons need no Mount. Installed on - * import; `toast-view.ts` imports it because every root renders the toaster. + * A focused button whose pending state flips is announced (RAC Button's pending announcement). One + * document observer follows `data-pending`, so buttons need no Mount. It is an app-lifetime + * Resource: the runtime installs the observer when the app starts and removes it on shutdown. */ -let generatedIds = 0 - -/** - * Disabling a focused button drops its focus before the observer runs; RAC reads the focus state - * of the render that set pending. So the button last focused or pressed (usePress focuses it on - * pointerdown) still counts while focus has fallen to the body and nothing else took it. - */ -let lastFocusedButton: HTMLButtonElement | null = null const buttonOf = (target: EventTarget | null) => (target instanceof Element ? target.closest("button") : null) -const watchPendingButtons = () => { - document.addEventListener( - "focusin", - (event) => { - lastFocusedButton = event.target instanceof HTMLButtonElement ? event.target : null - }, - true, - ) - document.addEventListener( - "pointerdown", - (event) => { - lastFocusedButton = buttonOf(event.target) - }, - true, - ) +const labelOf = (button: HTMLButtonElement) => + (button.getAttribute("aria-label") ?? button.textContent ?? "").trim() + +const watchPendingButtons = (): (() => void) => { + /** + * Disabling a focused button drops its focus before the observer runs, so the button last + * focused or pressed still counts while focus has fallen to the body and nothing else took it. + */ + let lastFocusedButton: HTMLButtonElement | null = null + const onFocusIn = (event: FocusEvent) => { + lastFocusedButton = event.target instanceof HTMLButtonElement ? event.target : null + } + const onPointerDown = (event: PointerEvent) => { + lastFocusedButton = buttonOf(event.target) + } const wasFocused = (element: HTMLButtonElement) => document.activeElement === element || (lastFocusedButton === element && (document.activeElement === null || document.activeElement === document.body)) const observer = new MutationObserver((records) => { - records.forEach((record) => { + for (const record of records) { const element = record.target const isPending = element instanceof Element && element.hasAttribute("data-pending") const wasPending = record.oldValue !== null if (element instanceof HTMLButtonElement && isPending !== wasPending && wasFocused(element)) { lastFocusedButton = null - if (element.id === "") element.id = `react-aria-button-${++generatedIds}` - announce({ labelledBy: element.id }) + const label = labelOf(element) + if (label !== "") announce(label) } - }) + } }) + document.addEventListener("focusin", onFocusIn, true) + document.addEventListener("pointerdown", onPointerDown, true) observer.observe(document.documentElement, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ["data-pending"], }) + return () => { + observer.disconnect() + document.removeEventListener("focusin", onFocusIn, true) + document.removeEventListener("pointerdown", onPointerDown, true) + } } -if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") watchPendingButtons() +/** The pending-button announcer, provided with the app's resources. */ +export const layer = Layer.effectDiscard( + Effect.acquireRelease(Effect.sync(watchPendingButtons), (release) => Effect.sync(release)), +) diff --git a/apps/web-foldkit/src/ui/toast-view.ts b/apps/web-foldkit/src/ui/toast-view.ts index 2bd788926..90a137849 100644 --- a/apps/web-foldkit/src/ui/toast-view.ts +++ b/apps/web-foldkit/src/ui/toast-view.ts @@ -5,7 +5,6 @@ import { toastStyles } from "~/components/ui/toast.styles" import { IconClose } from "../icons" import type { Action, Entry, Kind } from "./toast" // Pending-button announcements; every root renders the toaster, so they load with it. -import "./aria/pending-announcement" /** One toast's card, drawn inside the kit's entry `<div>`: icon, title, description, action, close. */