@jant/core 0.6.15 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
3
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
4
|
+
import { readComponentCss } from "../../../__tests__/helpers/component-css.js";
|
|
6
5
|
import type { Editor } from "@tiptap/core";
|
|
7
6
|
import { MAX_THREAD_POSTS } from "../../../types.js";
|
|
8
7
|
|
|
@@ -69,6 +68,45 @@ async function flushUpdates(el?: JantComposeDialog) {
|
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
|
|
71
|
+
async function openPublishPanel(el: JantComposeDialog) {
|
|
72
|
+
requireElement(
|
|
73
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
74
|
+
"expected publish settings toggle",
|
|
75
|
+
).click();
|
|
76
|
+
await flushUpdates(el);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Options-panel rows are found by their title — the drafts entries moved off
|
|
80
|
+
* the title bar and into that panel. */
|
|
81
|
+
function sheetRow(
|
|
82
|
+
el: JantComposeDialog,
|
|
83
|
+
title: string,
|
|
84
|
+
): HTMLButtonElement | null {
|
|
85
|
+
return (
|
|
86
|
+
Array.from(
|
|
87
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
88
|
+
".compose-publish-panel .compose-sheet-row",
|
|
89
|
+
),
|
|
90
|
+
).find(
|
|
91
|
+
(row) =>
|
|
92
|
+
row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
|
|
93
|
+
title,
|
|
94
|
+
) ?? null
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sheetRowTitles(el: JantComposeDialog): (string | undefined)[] {
|
|
99
|
+
return Array.from(
|
|
100
|
+
el.querySelectorAll<HTMLElement>(
|
|
101
|
+
".compose-publish-panel .compose-sheet-title",
|
|
102
|
+
),
|
|
103
|
+
).map((n) => n.textContent?.trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function draftsRow(el: JantComposeDialog): HTMLButtonElement | null {
|
|
107
|
+
return sheetRow(el, "Drafts");
|
|
108
|
+
}
|
|
109
|
+
|
|
72
110
|
function deferred<T>() {
|
|
73
111
|
let resolve!: (value: T) => void;
|
|
74
112
|
const promise = new Promise<T>((res) => {
|
|
@@ -120,6 +158,7 @@ function mockSlugApi(
|
|
|
120
158
|
|
|
121
159
|
const labels: ComposeLabels = {
|
|
122
160
|
cancel: "Cancel",
|
|
161
|
+
close: "Close",
|
|
123
162
|
note: "Note",
|
|
124
163
|
link: "Link",
|
|
125
164
|
quote: "Quote",
|
|
@@ -158,7 +197,11 @@ const labels: ComposeLabels = {
|
|
|
158
197
|
altHint: "Alt text improves accessibility",
|
|
159
198
|
addMore: "Add",
|
|
160
199
|
removeAttachment: "Remove attachment",
|
|
200
|
+
moveAttachmentEarlier: "Move attachment earlier",
|
|
201
|
+
moveAttachmentLater: "Move attachment later",
|
|
161
202
|
uploading: "Uploading...",
|
|
203
|
+
publishing: "Publishing...",
|
|
204
|
+
saving: "Saving...",
|
|
162
205
|
loadingPost: "Loading post...",
|
|
163
206
|
loadPostFailed: "Couldn't load this post. Try again.",
|
|
164
207
|
published: "Published!",
|
|
@@ -169,7 +212,7 @@ const labels: ComposeLabels = {
|
|
|
169
212
|
confirmCloseTitle: "Save to drafts?",
|
|
170
213
|
confirmCloseSubtitle: "Save to drafts to edit and post at a later time.",
|
|
171
214
|
confirmCloseSave: "Save",
|
|
172
|
-
confirmCloseCancel: "
|
|
215
|
+
confirmCloseCancel: "Keep editing",
|
|
173
216
|
confirmCloseDiscard: "Don't save",
|
|
174
217
|
confirmAttachedTitle: "Save text attachment?",
|
|
175
218
|
confirmAttachedSubtitle:
|
|
@@ -188,14 +231,32 @@ const labels: ComposeLabels = {
|
|
|
188
231
|
deleteDraft: "Delete Draft",
|
|
189
232
|
draftDeleted: "Draft deleted.",
|
|
190
233
|
publishFailedDraft: "Couldn't publish. Saved as draft.",
|
|
234
|
+
publishFailed: "Couldn't publish. Try again.",
|
|
235
|
+
publishFailedOffline: "Couldn't reach the server. Try again.",
|
|
191
236
|
uploadFailedDraft: "Some uploads failed. Saved as draft.",
|
|
192
237
|
addCollection: "Add Collection",
|
|
193
238
|
collectionCountLabel: "%name% + %count% more",
|
|
194
239
|
draftRestored: "Draft restored.",
|
|
240
|
+
draftStoreFailed: "This browser can't keep a local copy. Save as a draft.",
|
|
195
241
|
reply: "Reply",
|
|
196
242
|
publishHideFromLatest: "Hide from Latest",
|
|
197
243
|
publishPrivate: "Post as Private",
|
|
198
244
|
publishSettings: "Publish settings",
|
|
245
|
+
languageLabel: "Language",
|
|
246
|
+
languageAuto: "Detect",
|
|
247
|
+
languageAutoHint: "Read from what you write",
|
|
248
|
+
languageAutoDetected: "Read from what you write — looks like {language}",
|
|
249
|
+
languageAutoPending: "Not enough text to tell yet — publishes in {language}",
|
|
250
|
+
languageTriggerLabel: "Language: {language}",
|
|
251
|
+
translationOf: "Translation of “{title}”",
|
|
252
|
+
translationContext: "Translating",
|
|
253
|
+
translationContextInLanguage: "Translating into {language}",
|
|
254
|
+
translationContextOpen: "Open the original in a new tab",
|
|
255
|
+
translationContextOriginal: "The original",
|
|
256
|
+
translationContextHide: "Hide",
|
|
257
|
+
translationContextHideLong: "Hide the original",
|
|
258
|
+
translationContextShow: "Show",
|
|
259
|
+
translationContextShowLong: "Show the original",
|
|
199
260
|
publishVisibilityLabel: "Visibility",
|
|
200
261
|
publishVisibilityPublic: "Public",
|
|
201
262
|
publishVisibilityPublicHint: "Appears in Latest.",
|
|
@@ -211,13 +272,13 @@ const labels: ComposeLabels = {
|
|
|
211
272
|
publishDateInvalid: "Enter a valid date.",
|
|
212
273
|
publishDateFutureError:
|
|
213
274
|
"Choose today or an earlier date, or leave it blank to publish now.",
|
|
214
|
-
publishDateSummaryNow: "
|
|
275
|
+
publishDateSummaryNow: "Now",
|
|
215
276
|
publishDateSummaryAction: "Edit publish date",
|
|
216
277
|
publishSlugLabel: "Custom link",
|
|
217
278
|
publishSlugPlaceholder: "your-post-link",
|
|
218
279
|
publishSlugHint: "Leave blank to generate one automatically.",
|
|
219
280
|
publishSlugAuto: "Generate automatically",
|
|
220
|
-
publishSlugSummaryAuto: "Auto
|
|
281
|
+
publishSlugSummaryAuto: "Auto",
|
|
221
282
|
publishSlugSummaryAction: "Edit custom link",
|
|
222
283
|
publishSlugReset: "Reset link",
|
|
223
284
|
publishSlugSuggested: "Suggested link",
|
|
@@ -233,9 +294,8 @@ const labels: ComposeLabels = {
|
|
|
233
294
|
threadLimitReached: "Threads can include up to 20 posts.",
|
|
234
295
|
showMore: "Show more",
|
|
235
296
|
showLess: "Show less",
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
editTitle: "Edit",
|
|
297
|
+
editing: "Editing",
|
|
298
|
+
composeDialogLabel: "Compose",
|
|
239
299
|
slashHint: "Type / for commands",
|
|
240
300
|
tableControls: {
|
|
241
301
|
toolbarLabel: "Table controls",
|
|
@@ -263,13 +323,6 @@ const labels: ComposeLabels = {
|
|
|
263
323
|
quickHint: "More options are available after you create it.",
|
|
264
324
|
quickSubmitLabel: "Done",
|
|
265
325
|
createdLabel: "Collection created.",
|
|
266
|
-
descriptionLabel: "Description (optional)",
|
|
267
|
-
descriptionPlaceholder: "What's this collection about?",
|
|
268
|
-
sortOrderLabel: "Sort Order",
|
|
269
|
-
sortNewest: "Newest first",
|
|
270
|
-
sortOldest: "Oldest first",
|
|
271
|
-
sortRatingDesc: "Highest rated",
|
|
272
|
-
submitLabel: "Save",
|
|
273
326
|
cancelLabel: "Cancel",
|
|
274
327
|
},
|
|
275
328
|
};
|
|
@@ -279,6 +332,35 @@ const collections: ComposeCollection[] = [
|
|
|
279
332
|
{ id: "col-2", title: "Movies", slug: "movies" },
|
|
280
333
|
];
|
|
281
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Date and permalink hang off the post itself, not the publish panel. Both
|
|
337
|
+
* fields are open as soon as the panel is, so there is nothing further to
|
|
338
|
+
* expand. Idempotent.
|
|
339
|
+
*/
|
|
340
|
+
async function openPostMeta(el: JantComposeDialog, index = 0) {
|
|
341
|
+
const pill = el.querySelectorAll<HTMLButtonElement>(
|
|
342
|
+
"[data-compose-post-meta-pill]",
|
|
343
|
+
)[index];
|
|
344
|
+
if (pill && pill.getAttribute("aria-expanded") !== "true") pill.click();
|
|
345
|
+
// The slug suggestion is fetched when the panel opens; let it settle.
|
|
346
|
+
await flushUpdates(el);
|
|
347
|
+
await flushUpdates(el);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The date or permalink as the post's own pill states it. */
|
|
351
|
+
function pillValue(
|
|
352
|
+
el: JantComposeDialog,
|
|
353
|
+
kind: "date" | "slug",
|
|
354
|
+
index = 0,
|
|
355
|
+
): string | undefined {
|
|
356
|
+
const pill = el.querySelectorAll("[data-compose-post-meta-pill]")[index];
|
|
357
|
+
const selector =
|
|
358
|
+
kind === "date"
|
|
359
|
+
? ".compose-post-meta-value:not(.compose-post-meta-value-slug)"
|
|
360
|
+
: ".compose-post-meta-value-slug";
|
|
361
|
+
return pill?.querySelector(selector)?.textContent?.trim();
|
|
362
|
+
}
|
|
363
|
+
|
|
282
364
|
async function createElement(
|
|
283
365
|
cols: ComposeCollection[] = collections,
|
|
284
366
|
): Promise<JantComposeDialog> {
|
|
@@ -331,8 +413,11 @@ describe("JantComposeDialog", () => {
|
|
|
331
413
|
it("renders with collections and labels", async () => {
|
|
332
414
|
const el = await createElement();
|
|
333
415
|
|
|
334
|
-
//
|
|
335
|
-
|
|
416
|
+
// No title bar: the composer opens straight onto the post header row. A new
|
|
417
|
+
// post gets no marker either — an empty composer already says that much.
|
|
418
|
+
expect(el.querySelector(".compose-dialog-header")).toBeNull();
|
|
419
|
+
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
420
|
+
expect(el.querySelector(".compose-post-badge")).toBeNull();
|
|
336
421
|
|
|
337
422
|
// Format buttons present
|
|
338
423
|
const segmentedItems = el.querySelectorAll(".compose-segmented-item");
|
|
@@ -350,7 +435,7 @@ describe("JantComposeDialog", () => {
|
|
|
350
435
|
expect(postBtn.disabled).toBe(true);
|
|
351
436
|
expect(
|
|
352
437
|
requireElement(
|
|
353
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
438
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
354
439
|
"expected publish settings toggle",
|
|
355
440
|
).disabled,
|
|
356
441
|
).toBe(false);
|
|
@@ -382,7 +467,6 @@ describe("JantComposeDialog", () => {
|
|
|
382
467
|
],
|
|
383
468
|
},
|
|
384
469
|
title: "",
|
|
385
|
-
showTitle: false,
|
|
386
470
|
selection: { from: 3, to: 3 },
|
|
387
471
|
replyExpanded: false,
|
|
388
472
|
},
|
|
@@ -490,7 +574,7 @@ describe("JantComposeDialog", () => {
|
|
|
490
574
|
const el = await createElement();
|
|
491
575
|
|
|
492
576
|
requireElement(
|
|
493
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
577
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
494
578
|
"expected publish settings toggle",
|
|
495
579
|
).click();
|
|
496
580
|
await el.updateComplete;
|
|
@@ -498,15 +582,13 @@ describe("JantComposeDialog", () => {
|
|
|
498
582
|
expect(el.querySelector(".compose-publish-panel")).not.toBeNull();
|
|
499
583
|
});
|
|
500
584
|
|
|
501
|
-
it("
|
|
585
|
+
it("offers saving separately from browsing once there is something to save", async () => {
|
|
502
586
|
const el = await createElement();
|
|
587
|
+
await openPublishPanel(el);
|
|
503
588
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
);
|
|
508
|
-
expect(draftButton.title).toBe("Drafts");
|
|
509
|
-
expect(draftButton.getAttribute("aria-label")).toBe("Drafts");
|
|
589
|
+
// Nothing written yet, so there is only a place to go.
|
|
590
|
+
expect(sheetRowTitles(el)).toContain("Drafts");
|
|
591
|
+
expect(sheetRowTitles(el)).not.toContain("Save as draft");
|
|
510
592
|
|
|
511
593
|
const editor = requireElement(
|
|
512
594
|
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
@@ -519,201 +601,1673 @@ describe("JantComposeDialog", () => {
|
|
|
519
601
|
],
|
|
520
602
|
};
|
|
521
603
|
await editor.updateComplete;
|
|
522
|
-
await el
|
|
604
|
+
await flushUpdates(el);
|
|
523
605
|
|
|
524
|
-
|
|
525
|
-
|
|
606
|
+
// Now both: an action on this post, and the list. Neither label lies about
|
|
607
|
+
// what its row does.
|
|
608
|
+
const titles = sheetRowTitles(el);
|
|
609
|
+
expect(titles).toContain("Save as draft");
|
|
610
|
+
expect(titles).toContain("Drafts");
|
|
611
|
+
expect(titles.indexOf("Save as draft")).toBeLessThan(
|
|
612
|
+
titles.indexOf("Drafts"),
|
|
613
|
+
);
|
|
526
614
|
});
|
|
527
615
|
|
|
528
|
-
it("
|
|
616
|
+
it("saves straight to a draft from its own row, with no prompt in the way", async () => {
|
|
529
617
|
const el = await createElement();
|
|
618
|
+
const editor = requireElement(
|
|
619
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
620
|
+
"expected compose editor",
|
|
621
|
+
);
|
|
622
|
+
editor._bodyJson = {
|
|
623
|
+
type: "doc",
|
|
624
|
+
content: [
|
|
625
|
+
{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] },
|
|
626
|
+
],
|
|
627
|
+
};
|
|
628
|
+
await editor.updateComplete;
|
|
629
|
+
await flushUpdates(el);
|
|
630
|
+
|
|
631
|
+
let detail: ComposeSubmitDetail | null = null;
|
|
632
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
633
|
+
detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
634
|
+
});
|
|
530
635
|
|
|
636
|
+
await openPublishPanel(el);
|
|
531
637
|
requireElement(
|
|
532
|
-
el
|
|
533
|
-
"expected
|
|
638
|
+
sheetRow(el, "Save as draft"),
|
|
639
|
+
"expected save-as-draft row",
|
|
534
640
|
).click();
|
|
535
|
-
await el
|
|
641
|
+
await flushUpdates(el);
|
|
536
642
|
|
|
537
|
-
|
|
538
|
-
expect(el.
|
|
539
|
-
expect(
|
|
643
|
+
expect(el._confirmPanelOpen).toBe(false);
|
|
644
|
+
expect(el._draftsPanelOpen).toBe(false);
|
|
645
|
+
expect(detail).not.toBeNull();
|
|
646
|
+
expect(detail!.status).toBe("draft");
|
|
647
|
+
});
|
|
540
648
|
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
649
|
+
it("opens options from its own button, not a chevron on Publish", async () => {
|
|
650
|
+
const el = await createElement();
|
|
651
|
+
|
|
652
|
+
// The split button is gone: Publish is one button, options is another.
|
|
653
|
+
expect(el.querySelector(".compose-publish-toggle")).toBeNull();
|
|
654
|
+
|
|
655
|
+
const trigger = requireElement(
|
|
656
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
657
|
+
"expected options trigger",
|
|
544
658
|
);
|
|
545
|
-
|
|
546
|
-
|
|
659
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
660
|
+
expect(el.querySelector(".compose-sheet")).toBeNull();
|
|
661
|
+
|
|
662
|
+
trigger.click();
|
|
663
|
+
await flushUpdates(el);
|
|
664
|
+
|
|
665
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
666
|
+
expect(el.querySelector(".compose-sheet")).not.toBeNull();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe("language", () => {
|
|
670
|
+
function languageTrigger(el: JantComposeDialog): HTMLButtonElement | null {
|
|
671
|
+
return el.querySelector<HTMLButtonElement>(".compose-language-trigger");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function openLanguagePicker(el: JantComposeDialog) {
|
|
675
|
+
requireElement(languageTrigger(el), "expected language pill").click();
|
|
676
|
+
await flushUpdates(el);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function languageRowTitles(el: JantComposeDialog): string[] {
|
|
680
|
+
const group = el.querySelector(
|
|
681
|
+
".compose-language-popover [role='radiogroup']",
|
|
682
|
+
);
|
|
683
|
+
return Array.from(
|
|
684
|
+
group?.querySelectorAll(".compose-sheet-title") ?? [],
|
|
685
|
+
).map((node) => node.textContent?.trim() ?? "");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
it("shows nothing on a site that publishes one language", async () => {
|
|
689
|
+
// The whole feature is opt-in: an author who never turned it on should
|
|
690
|
+
// not meet a language control.
|
|
691
|
+
const el = await createElement();
|
|
692
|
+
|
|
693
|
+
expect(languageTrigger(el)).toBeNull();
|
|
694
|
+
expect(el.textContent).not.toContain("Detect");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("offers Detect plus each language once the site is multilingual", async () => {
|
|
698
|
+
const el = await createElement();
|
|
699
|
+
el.languages = [
|
|
700
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
701
|
+
{ tag: "en", label: "English" },
|
|
702
|
+
];
|
|
703
|
+
await flushUpdates(el);
|
|
704
|
+
await openLanguagePicker(el);
|
|
705
|
+
|
|
706
|
+
expect(languageRowTitles(el)).toEqual(["Detect", "简体中文", "English"]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("stays a bare globe while the answer is the page you are on", async () => {
|
|
710
|
+
const el = await createElement();
|
|
711
|
+
el.languages = [
|
|
712
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
713
|
+
{ tag: "en", label: "English" },
|
|
714
|
+
];
|
|
715
|
+
el.contextLanguage = "en";
|
|
716
|
+
await flushUpdates(el);
|
|
717
|
+
|
|
718
|
+
// Writing English from /en: the globe is the whole message. The answer
|
|
719
|
+
// is still named for anyone who asks for it.
|
|
720
|
+
const trigger = requireElement(
|
|
721
|
+
languageTrigger(el),
|
|
722
|
+
"expected language pill",
|
|
723
|
+
);
|
|
724
|
+
expect(trigger.textContent?.trim()).toBe("");
|
|
725
|
+
expect(trigger.getAttribute("aria-label")).toBe("Language: English");
|
|
726
|
+
expect(trigger.getAttribute("title")).toBe("Language: English");
|
|
727
|
+
expect(trigger.dataset.chosen).toBeUndefined();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("grows the language name once detection moves it", async () => {
|
|
731
|
+
const el = await createElement();
|
|
732
|
+
el.languages = [
|
|
733
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
734
|
+
{ tag: "en", label: "English" },
|
|
735
|
+
];
|
|
736
|
+
el.contextLanguage = "en";
|
|
737
|
+
await flushUpdates(el);
|
|
738
|
+
|
|
739
|
+
const editor = requireElement(
|
|
740
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
741
|
+
"expected compose editor",
|
|
742
|
+
);
|
|
743
|
+
const write = async (text: string) => {
|
|
744
|
+
editor._bodyJson = {
|
|
745
|
+
type: "doc",
|
|
746
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
747
|
+
};
|
|
748
|
+
await editor.updateComplete;
|
|
749
|
+
await flushUpdates(el);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// A fragment is not evidence: two characters leave the pill alone.
|
|
753
|
+
await write("这会");
|
|
754
|
+
expect(languageTrigger(el)?.textContent?.trim()).toBe("");
|
|
755
|
+
|
|
756
|
+
// A sentence is. The pill says so itself, with nothing opened.
|
|
757
|
+
await write("这会说国学时间");
|
|
758
|
+
expect(languageTrigger(el)?.textContent?.trim()).toBe("简体中文");
|
|
759
|
+
expect(languageTrigger(el)?.getAttribute("aria-label")).toBe(
|
|
760
|
+
"Language: 简体中文",
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("marks the pill once the author picks a language themselves", async () => {
|
|
765
|
+
const el = await createElement();
|
|
766
|
+
el.languages = [
|
|
767
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
768
|
+
{ tag: "en", label: "English" },
|
|
769
|
+
];
|
|
770
|
+
el.contextLanguage = "en";
|
|
771
|
+
await flushUpdates(el);
|
|
772
|
+
await openLanguagePicker(el);
|
|
773
|
+
|
|
774
|
+
const rows = Array.from(
|
|
775
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
776
|
+
".compose-language-popover .compose-sheet-row",
|
|
777
|
+
),
|
|
778
|
+
);
|
|
779
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
780
|
+
await flushUpdates(el);
|
|
781
|
+
|
|
782
|
+
// Choosing closes the list behind the choice.
|
|
783
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
784
|
+
const trigger = requireElement(
|
|
785
|
+
languageTrigger(el),
|
|
786
|
+
"expected language pill",
|
|
787
|
+
);
|
|
788
|
+
expect(trigger.textContent?.trim()).toBe("简体中文");
|
|
789
|
+
expect(trigger.dataset.chosen).toBe("true");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("says the text is too short rather than claiming a reading", async () => {
|
|
793
|
+
// Below the detector's threshold there is no reading to report. Naming
|
|
794
|
+
// the page's language here would pass a fallback off as an answer.
|
|
795
|
+
const el = await createElement();
|
|
796
|
+
el.languages = [
|
|
797
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
798
|
+
{ tag: "en", label: "English" },
|
|
799
|
+
];
|
|
800
|
+
el.contextLanguage = "zh-Hans";
|
|
801
|
+
await flushUpdates(el);
|
|
802
|
+
await openLanguagePicker(el);
|
|
803
|
+
|
|
804
|
+
const hint = () =>
|
|
805
|
+
el
|
|
806
|
+
.querySelector(".compose-language-popover .compose-sheet-sub")
|
|
807
|
+
?.textContent?.trim();
|
|
808
|
+
expect(hint()).toBe(
|
|
809
|
+
"Not enough text to tell yet — publishes in 简体中文",
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const editor = requireElement(
|
|
813
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
814
|
+
"expected compose editor",
|
|
815
|
+
);
|
|
816
|
+
editor._bodyJson = {
|
|
817
|
+
type: "doc",
|
|
818
|
+
content: [
|
|
819
|
+
{
|
|
820
|
+
type: "paragraph",
|
|
821
|
+
content: [{ type: "text", text: "Hello there, in English." }],
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
};
|
|
825
|
+
await editor.updateComplete;
|
|
826
|
+
await flushUpdates(el);
|
|
827
|
+
|
|
828
|
+
expect(hint()).toBe("Read from what you write — looks like English");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("keeps the Detect row honest after an explicit choice", async () => {
|
|
832
|
+
// The row offers to read the text, so it has to report what reading it
|
|
833
|
+
// produces — not echo back the language the author picked instead.
|
|
834
|
+
const el = await createElement();
|
|
835
|
+
el.languages = [
|
|
836
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
837
|
+
{ tag: "en", label: "English" },
|
|
838
|
+
];
|
|
839
|
+
el.contextLanguage = "en";
|
|
840
|
+
await flushUpdates(el);
|
|
841
|
+
|
|
842
|
+
const editor = requireElement(
|
|
843
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
844
|
+
"expected compose editor",
|
|
845
|
+
);
|
|
846
|
+
editor._bodyJson = {
|
|
847
|
+
type: "doc",
|
|
848
|
+
content: [
|
|
849
|
+
{
|
|
850
|
+
type: "paragraph",
|
|
851
|
+
content: [{ type: "text", text: "Hello there, in English." }],
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
};
|
|
855
|
+
await editor.updateComplete;
|
|
856
|
+
await flushUpdates(el);
|
|
857
|
+
await openLanguagePicker(el);
|
|
858
|
+
|
|
859
|
+
const rows = Array.from(
|
|
860
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
861
|
+
".compose-language-popover .compose-sheet-row",
|
|
862
|
+
),
|
|
863
|
+
);
|
|
864
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
865
|
+
await flushUpdates(el);
|
|
866
|
+
await openLanguagePicker(el);
|
|
867
|
+
|
|
868
|
+
const hint = el.querySelector(
|
|
869
|
+
".compose-language-popover .compose-sheet-sub",
|
|
870
|
+
);
|
|
871
|
+
expect(hint?.textContent?.trim()).toBe(
|
|
872
|
+
"Read from what you write — looks like English",
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("closes the list on Escape and hands focus back to the pill", async () => {
|
|
877
|
+
const el = await createElement();
|
|
878
|
+
el.languages = [
|
|
879
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
880
|
+
{ tag: "en", label: "English" },
|
|
881
|
+
];
|
|
882
|
+
await flushUpdates(el);
|
|
883
|
+
await openLanguagePicker(el);
|
|
884
|
+
expect(el.querySelector(".compose-language-popover")).not.toBeNull();
|
|
885
|
+
|
|
886
|
+
el.dispatchEvent(
|
|
887
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
888
|
+
);
|
|
889
|
+
await flushUpdates(el);
|
|
890
|
+
|
|
891
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
892
|
+
expect(document.activeElement).toBe(languageTrigger(el));
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("closes the list when another control opens", async () => {
|
|
896
|
+
const el = await createElement();
|
|
897
|
+
el.languages = [
|
|
898
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
899
|
+
{ tag: "en", label: "English" },
|
|
900
|
+
];
|
|
901
|
+
await flushUpdates(el);
|
|
902
|
+
await openLanguagePicker(el);
|
|
903
|
+
|
|
904
|
+
await openPublishPanel(el);
|
|
905
|
+
|
|
906
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
907
|
+
expect(el.querySelector(".compose-sheet")).not.toBeNull();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it("keeps language out of the options sheet", async () => {
|
|
911
|
+
// One setting, one place. The sheet speaks for the whole submission; the
|
|
912
|
+
// language belongs to what is being written.
|
|
913
|
+
const el = await createElement();
|
|
914
|
+
el.languages = [
|
|
915
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
916
|
+
{ tag: "en", label: "English" },
|
|
917
|
+
];
|
|
918
|
+
await flushUpdates(el);
|
|
919
|
+
|
|
920
|
+
await openPublishPanel(el);
|
|
921
|
+
|
|
922
|
+
expect(
|
|
923
|
+
el.querySelector(".compose-sheet")?.textContent ?? "",
|
|
924
|
+
).not.toContain("Detect");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Open the composer on a translation of `post`. The composer asks for two
|
|
929
|
+
* things: the post's fields, to seed from, and its server-rendered markup,
|
|
930
|
+
* to show.
|
|
931
|
+
*/
|
|
932
|
+
async function openTranslationOf(
|
|
933
|
+
el: JantComposeDialog,
|
|
934
|
+
post: Record<string, unknown>,
|
|
935
|
+
options: { language?: string; previewHtml?: string | null } = {},
|
|
936
|
+
) {
|
|
937
|
+
const { language = "en", previewHtml = null } = options;
|
|
938
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((input: unknown) => {
|
|
939
|
+
if (String(input).includes("/_/post-preview/")) {
|
|
940
|
+
return Promise.resolve({
|
|
941
|
+
ok: previewHtml !== null,
|
|
942
|
+
text: async () => previewHtml ?? "",
|
|
943
|
+
} as Response);
|
|
944
|
+
}
|
|
945
|
+
return Promise.resolve({
|
|
946
|
+
ok: true,
|
|
947
|
+
json: async () => post,
|
|
948
|
+
} as Response);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
await el.openTranslation((post.id as string) ?? "pst_source", language);
|
|
952
|
+
await flushUpdates(el);
|
|
953
|
+
await flushUpdates(el);
|
|
954
|
+
await flushUpdates(el);
|
|
955
|
+
return el;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function multilingualElement() {
|
|
959
|
+
const el = await createElement();
|
|
960
|
+
el.languages = [
|
|
961
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
962
|
+
{ tag: "en", label: "English" },
|
|
963
|
+
];
|
|
964
|
+
await flushUpdates(el);
|
|
965
|
+
return el;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const PREVIEW_HTML =
|
|
969
|
+
'<article class="h-entry post-menu-target" data-post data-post-id="pst_source" data-page="post">' +
|
|
970
|
+
'<h1 class="post-detail-title">咖啡笔记</h1>' +
|
|
971
|
+
'<div class="post-header-meta-row"><a href="/coffee" class="post-header-meta-link">Aug 8, 2026</a></div>' +
|
|
972
|
+
'<div class="e-content prose post-detail-body"><h2>耶加雪菲</h2>' +
|
|
973
|
+
'<p>今天试了一支,<a href="https://example.com/beans">花香</a>很明显。</p></div>' +
|
|
974
|
+
"</article>";
|
|
975
|
+
|
|
976
|
+
it("says what it was opened to translate, before anything is typed", async () => {
|
|
977
|
+
// Opened from a post, so the composer should carry that context above the
|
|
978
|
+
// editor — not two panels down in the publish sheet.
|
|
979
|
+
const el = await multilingualElement();
|
|
980
|
+
await openTranslationOf(
|
|
981
|
+
el,
|
|
982
|
+
{
|
|
983
|
+
id: "pst_source",
|
|
984
|
+
format: "note",
|
|
985
|
+
displayTitle: "咖啡笔记",
|
|
986
|
+
slug: "coffee",
|
|
987
|
+
},
|
|
988
|
+
{ previewHtml: PREVIEW_HTML },
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const banner = requireElement(
|
|
992
|
+
el.querySelector(".compose-translation-context"),
|
|
993
|
+
"expected the translation context",
|
|
994
|
+
);
|
|
995
|
+
// The language sits on the seam between the two posts, not above both:
|
|
996
|
+
// it reads as "…and below is that, in English".
|
|
997
|
+
expect(
|
|
998
|
+
banner
|
|
999
|
+
.querySelector(".compose-translation-seam-label")
|
|
1000
|
+
?.textContent?.trim(),
|
|
1001
|
+
).toBe("Translating into English");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("shows the original as the site renders it, inside a frame to scroll", async () => {
|
|
1005
|
+
// Server-rendered rather than rebuilt here, so a Quote arrives with its
|
|
1006
|
+
// attribution and a Link with its card. The frame is fixed because an
|
|
1007
|
+
// original handed its full height pushes the editor off the screen
|
|
1008
|
+
// exactly when both need to be visible.
|
|
1009
|
+
const el = await multilingualElement();
|
|
1010
|
+
await openTranslationOf(
|
|
1011
|
+
el,
|
|
1012
|
+
{
|
|
1013
|
+
id: "pst_source",
|
|
1014
|
+
format: "note",
|
|
1015
|
+
displayTitle: "咖啡笔记",
|
|
1016
|
+
slug: "coffee",
|
|
1017
|
+
},
|
|
1018
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
const original = requireElement(
|
|
1022
|
+
el.querySelector(".compose-translation-original"),
|
|
1023
|
+
"expected the original's frame",
|
|
1024
|
+
);
|
|
1025
|
+
// Keyboard users have to be able to scroll it, and a screen reader has to
|
|
1026
|
+
// be able to name it.
|
|
1027
|
+
expect(original.getAttribute("tabindex")).toBe("0");
|
|
1028
|
+
expect(original.getAttribute("role")).toBe("region");
|
|
1029
|
+
expect(original.getAttribute("aria-label")).toBe("The original");
|
|
1030
|
+
// Structure is part of what is being translated, so it survives intact.
|
|
1031
|
+
expect(original.querySelector("h1")?.textContent).toBe("咖啡笔记");
|
|
1032
|
+
expect(original.querySelector("h2")?.textContent).toBe("耶加雪菲");
|
|
1033
|
+
|
|
1034
|
+
// Nothing left of the expand-in-place preview it replaced, and no bolted
|
|
1035
|
+
// on external-link glyph — that made the card read as a Link post.
|
|
1036
|
+
expect(el.querySelector(".compose-translation-toggle")).toBeNull();
|
|
1037
|
+
expect(el.querySelector(".compose-translation-source-icon")).toBeNull();
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it("folds the original away and back, keeping the seam either way", async () => {
|
|
1041
|
+
// The frame costs vertical room the editor may want back mid-draft. What
|
|
1042
|
+
// it is being translated into stays on screen while it is folded — that
|
|
1043
|
+
// is about the two posts, not about how much of one is showing.
|
|
1044
|
+
const el = await multilingualElement();
|
|
1045
|
+
await openTranslationOf(
|
|
1046
|
+
el,
|
|
1047
|
+
{
|
|
1048
|
+
id: "pst_source",
|
|
1049
|
+
format: "note",
|
|
1050
|
+
displayTitle: "咖啡笔记",
|
|
1051
|
+
slug: "coffee",
|
|
1052
|
+
},
|
|
1053
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
const toggle = () =>
|
|
1057
|
+
requireElement(
|
|
1058
|
+
el.querySelector<HTMLButtonElement>(
|
|
1059
|
+
".compose-translation-seam-toggle",
|
|
1060
|
+
),
|
|
1061
|
+
"expected the fold toggle",
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
expect(el.querySelector(".compose-translation-original")).not.toBeNull();
|
|
1065
|
+
expect(toggle().getAttribute("aria-expanded")).toBe("true");
|
|
1066
|
+
expect(toggle().textContent?.trim()).toBe("Hide");
|
|
1067
|
+
|
|
1068
|
+
toggle().click();
|
|
1069
|
+
await flushUpdates(el);
|
|
1070
|
+
|
|
1071
|
+
expect(el.querySelector(".compose-translation-original")).toBeNull();
|
|
1072
|
+
expect(toggle().getAttribute("aria-expanded")).toBe("false");
|
|
1073
|
+
expect(toggle().textContent?.trim()).toBe("Show");
|
|
1074
|
+
expect(toggle().getAttribute("aria-label")).toBe("Show the original");
|
|
1075
|
+
expect(
|
|
1076
|
+
el
|
|
1077
|
+
.querySelector(".compose-translation-seam-label")
|
|
1078
|
+
?.textContent?.trim(),
|
|
1079
|
+
).toBe("Translating into English");
|
|
1080
|
+
|
|
1081
|
+
toggle().click();
|
|
1082
|
+
await flushUpdates(el);
|
|
1083
|
+
|
|
1084
|
+
// Unfolding re-creates the markup, so the links have to be sent to a new
|
|
1085
|
+
// tab again — the adopt pass runs on every update, not just the first.
|
|
1086
|
+
const link = requireElement(
|
|
1087
|
+
el.querySelector<HTMLAnchorElement>(
|
|
1088
|
+
".compose-translation-preview a[href]",
|
|
1089
|
+
),
|
|
1090
|
+
"expected the original back",
|
|
1091
|
+
);
|
|
1092
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("sends every link in the original to a new tab", async () => {
|
|
1096
|
+
// A link followed in place navigates the composer away and takes the
|
|
1097
|
+
// unsaved translation with it.
|
|
1098
|
+
const el = await multilingualElement();
|
|
1099
|
+
await openTranslationOf(
|
|
1100
|
+
el,
|
|
1101
|
+
{
|
|
1102
|
+
id: "pst_source",
|
|
1103
|
+
format: "note",
|
|
1104
|
+
displayTitle: "咖啡笔记",
|
|
1105
|
+
slug: "coffee",
|
|
1106
|
+
},
|
|
1107
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
const links = Array.from(
|
|
1111
|
+
el.querySelectorAll<HTMLAnchorElement>(
|
|
1112
|
+
".compose-translation-preview a[href]",
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
expect(links.length).toBe(2);
|
|
1116
|
+
for (const link of links) {
|
|
1117
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1118
|
+
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("strips the original's identity so nothing else can act on it", async () => {
|
|
1123
|
+
// `data-post-id` is how the post menu, the keyboard shortcuts and
|
|
1124
|
+
// `refreshArticleView` find a post. A second copy of the original's id in
|
|
1125
|
+
// the DOM lets any of them act on the preview believing it is the card.
|
|
1126
|
+
const el = await multilingualElement();
|
|
1127
|
+
await openTranslationOf(
|
|
1128
|
+
el,
|
|
1129
|
+
{
|
|
1130
|
+
id: "pst_source",
|
|
1131
|
+
format: "note",
|
|
1132
|
+
displayTitle: "咖啡笔记",
|
|
1133
|
+
slug: "coffee",
|
|
1134
|
+
},
|
|
1135
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const preview = requireElement(
|
|
1139
|
+
el.querySelector(".compose-translation-preview"),
|
|
1140
|
+
"expected the preview",
|
|
1141
|
+
);
|
|
1142
|
+
expect(preview.querySelector("[data-post-id]")).toBeNull();
|
|
1143
|
+
expect(preview.querySelector(".post-menu-target")).toBeNull();
|
|
1144
|
+
expect(
|
|
1145
|
+
document.querySelectorAll('[data-post-id="pst_source"]').length,
|
|
1146
|
+
).toBe(0);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("falls back to a plain link when the rendering cannot be fetched", async () => {
|
|
1150
|
+
const el = await multilingualElement();
|
|
1151
|
+
await openTranslationOf(
|
|
1152
|
+
el,
|
|
1153
|
+
{
|
|
1154
|
+
id: "pst_source",
|
|
1155
|
+
format: "note",
|
|
1156
|
+
displayTitle: "咖啡笔记",
|
|
1157
|
+
slug: "coffee",
|
|
1158
|
+
},
|
|
1159
|
+
{ previewHtml: null },
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
expect(el.querySelector(".compose-translation-original")).toBeNull();
|
|
1163
|
+
const link = requireElement(
|
|
1164
|
+
el.querySelector<HTMLAnchorElement>(".compose-translation-fallback a"),
|
|
1165
|
+
"expected a fallback link to the original",
|
|
1166
|
+
);
|
|
1167
|
+
expect(link.textContent?.trim()).toBe("咖啡笔记");
|
|
1168
|
+
expect(link.getAttribute("href")).toBe("/coffee");
|
|
1169
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1170
|
+
// The seam still names the language: it is about the two posts, not about
|
|
1171
|
+
// how much of one of them could be shown.
|
|
1172
|
+
expect(
|
|
1173
|
+
el
|
|
1174
|
+
.querySelector(".compose-translation-seam-label")
|
|
1175
|
+
?.textContent?.trim(),
|
|
1176
|
+
).toBe("Translating into English");
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it("starts a translation in the original's shape, down to its meta", async () => {
|
|
1180
|
+
// Everything that describes the post's subject or its place travels:
|
|
1181
|
+
// format, citation, collections, visibility, rating. Only the words are
|
|
1182
|
+
// language-specific, so only the words start empty.
|
|
1183
|
+
const el = await multilingualElement();
|
|
1184
|
+
await openTranslationOf(el, {
|
|
1185
|
+
id: "pst_source",
|
|
1186
|
+
format: "quote",
|
|
1187
|
+
displayTitle: "关于专注",
|
|
1188
|
+
slug: "focus",
|
|
1189
|
+
quoteText: "专注是一种拒绝。",
|
|
1190
|
+
sourceName: "Some Author",
|
|
1191
|
+
sourceUrl: "https://example.com/focus",
|
|
1192
|
+
collectionIds: ["col_reading", "col_notes"],
|
|
1193
|
+
visibility: "latest_hidden",
|
|
1194
|
+
rating: 4,
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
const editor = requireElement(
|
|
1198
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1199
|
+
"expected the editor",
|
|
1200
|
+
);
|
|
1201
|
+
expect(editor.format).toBe("quote");
|
|
1202
|
+
const data = editor.getData();
|
|
1203
|
+
expect(data.url).toBe("https://example.com/focus");
|
|
1204
|
+
expect(data.quoteAuthor).toBe("Some Author");
|
|
1205
|
+
expect(data.rating).toBe(4);
|
|
1206
|
+
// The prose is the part the author is here to write.
|
|
1207
|
+
expect(data.quoteText).toBe("");
|
|
1208
|
+
|
|
1209
|
+
const internals = el as unknown as {
|
|
1210
|
+
_collectionIds: string[];
|
|
1211
|
+
_visibility: string;
|
|
1212
|
+
_hasUnsavedChanges: () => boolean;
|
|
1213
|
+
};
|
|
1214
|
+
expect(internals._collectionIds).toEqual(["col_reading", "col_notes"]);
|
|
1215
|
+
expect(internals._visibility).toBe("latest_hidden");
|
|
1216
|
+
|
|
1217
|
+
// None of that counts as something to discard: the author typed nothing.
|
|
1218
|
+
expect(internals._hasUnsavedChanges()).toBe(false);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("closes a seeded translation without offering to save it", async () => {
|
|
1222
|
+
// The citation arrived from the original, not from the author. Being
|
|
1223
|
+
// asked to save a draft of a URL you never typed is worse than the
|
|
1224
|
+
// prompt is worth — but the moment anything is written, it comes back.
|
|
1225
|
+
const el = await multilingualElement();
|
|
1226
|
+
await openTranslationOf(el, {
|
|
1227
|
+
id: "pst_source",
|
|
1228
|
+
format: "quote",
|
|
1229
|
+
displayTitle: "关于专注",
|
|
1230
|
+
slug: "focus",
|
|
1231
|
+
sourceUrl: "https://example.com/focus",
|
|
1232
|
+
});
|
|
1233
|
+
const internals = el as unknown as {
|
|
1234
|
+
_confirmPanelOpen: boolean;
|
|
1235
|
+
_hasContent: () => boolean;
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
el.requestClose();
|
|
1239
|
+
await flushUpdates(el);
|
|
1240
|
+
expect(internals._confirmPanelOpen).toBe(false);
|
|
1241
|
+
|
|
1242
|
+
await openTranslationOf(el, {
|
|
1243
|
+
id: "pst_source",
|
|
1244
|
+
format: "quote",
|
|
1245
|
+
displayTitle: "关于专注",
|
|
1246
|
+
slug: "focus",
|
|
1247
|
+
sourceUrl: "https://example.com/focus",
|
|
1248
|
+
});
|
|
1249
|
+
const editor = requireElement(
|
|
1250
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1251
|
+
"expected the editor",
|
|
1252
|
+
);
|
|
1253
|
+
editor._quoteText = "Focus is about saying no.";
|
|
1254
|
+
await flushUpdates(el);
|
|
1255
|
+
|
|
1256
|
+
el.requestClose();
|
|
1257
|
+
await flushUpdates(el);
|
|
1258
|
+
expect(internals._confirmPanelOpen).toBe(true);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("keeps the citation, the original and the language when a translation grows into a thread", async () => {
|
|
1262
|
+
// The thread path used to be a copy of the single-post one: "Add to
|
|
1263
|
+
// thread" dropped the quote's source link on the way into the thread
|
|
1264
|
+
// editor, and the submit never learned about language or translations.
|
|
1265
|
+
const el = await multilingualElement();
|
|
1266
|
+
await openTranslationOf(el, {
|
|
1267
|
+
id: "pst_source",
|
|
1268
|
+
format: "quote",
|
|
1269
|
+
displayTitle: "关于专注",
|
|
1270
|
+
slug: "focus",
|
|
1271
|
+
quoteText: "专注是一种拒绝。",
|
|
1272
|
+
sourceName: "Some Author",
|
|
1273
|
+
sourceUrl: "https://example.com/focus",
|
|
1274
|
+
});
|
|
1275
|
+
(el as unknown as { _language: string | null })._language = "en";
|
|
1276
|
+
const single = requireElement(
|
|
1277
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1278
|
+
"expected the editor",
|
|
1279
|
+
);
|
|
1280
|
+
single._quoteText = "Focus is a refusal.";
|
|
1281
|
+
await flushUpdates(el);
|
|
1282
|
+
|
|
1283
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
1284
|
+
await flushUpdates(el);
|
|
1285
|
+
const editors = Array.from(
|
|
1286
|
+
el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
|
|
1287
|
+
);
|
|
1288
|
+
expect(editors).toHaveLength(2);
|
|
1289
|
+
// The next post starts in the format of the one before it.
|
|
1290
|
+
editors[1]!._quoteText = "And its answer.";
|
|
1291
|
+
await flushUpdates(el);
|
|
1292
|
+
|
|
1293
|
+
let detail: ComposeSubmitDetail | null = null;
|
|
1294
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
1295
|
+
detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
1296
|
+
});
|
|
1297
|
+
requireElement(
|
|
1298
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
1299
|
+
"expected publish button",
|
|
1300
|
+
).click();
|
|
1301
|
+
|
|
1302
|
+
const [root, reply] = (detail as ComposeSubmitDetail | null)
|
|
1303
|
+
?.threadPosts ?? [undefined, undefined];
|
|
1304
|
+
expect(root).toMatchObject({
|
|
1305
|
+
format: "quote",
|
|
1306
|
+
quoteText: "Focus is a refusal.",
|
|
1307
|
+
quoteAuthor: "Some Author",
|
|
1308
|
+
url: "https://example.com/focus",
|
|
1309
|
+
translationOfId: "pst_source",
|
|
1310
|
+
language: "en",
|
|
1311
|
+
});
|
|
1312
|
+
// Thread-level facts: only the root states them.
|
|
1313
|
+
expect(reply?.translationOfId).toBeUndefined();
|
|
1314
|
+
expect(reply?.language).toBeUndefined();
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it("leaves the format alone once the author has started writing", async () => {
|
|
1318
|
+
// The fetch lands after the composer opens. Overwriting a first sentence
|
|
1319
|
+
// to save a format click is a bad trade.
|
|
1320
|
+
const el = await multilingualElement();
|
|
1321
|
+
vi.spyOn(
|
|
1322
|
+
el as unknown as { _hasContent: () => boolean },
|
|
1323
|
+
"_hasContent",
|
|
1324
|
+
).mockReturnValue(true);
|
|
1325
|
+
|
|
1326
|
+
await openTranslationOf(el, {
|
|
1327
|
+
id: "pst_source",
|
|
1328
|
+
format: "quote",
|
|
1329
|
+
displayTitle: "关于专注",
|
|
1330
|
+
slug: "focus",
|
|
1331
|
+
sourceUrl: "https://example.com/focus",
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const editor = requireElement(
|
|
1335
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1336
|
+
"expected the editor",
|
|
1337
|
+
);
|
|
1338
|
+
expect(editor.format).toBe("note");
|
|
1339
|
+
expect(editor.getData().url).toBe("");
|
|
1340
|
+
// The context still shows — it is the one thing that never fights the
|
|
1341
|
+
// author for the page.
|
|
1342
|
+
expect(el.querySelector(".compose-translation-context")).not.toBeNull();
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("sends the chosen language, and the page's language on Detect", async () => {
|
|
1346
|
+
const el = await createElement();
|
|
1347
|
+
el.languages = [
|
|
1348
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1349
|
+
{ tag: "en", label: "English" },
|
|
1350
|
+
];
|
|
1351
|
+
el.contextLanguage = "en";
|
|
1352
|
+
await flushUpdates(el);
|
|
1353
|
+
await openLanguagePicker(el);
|
|
1354
|
+
|
|
1355
|
+
const build = (
|
|
1356
|
+
el as unknown as {
|
|
1357
|
+
_buildSubmitDetail: (
|
|
1358
|
+
status: "published" | "draft",
|
|
1359
|
+
) => ComposeSubmitDetail | null;
|
|
1360
|
+
}
|
|
1361
|
+
)._buildSubmitDetail.bind(el);
|
|
1362
|
+
|
|
1363
|
+
// Left on Detect with no text signal: the post belongs to the page it
|
|
1364
|
+
// was written from, so the page's language goes out explicitly.
|
|
1365
|
+
expect(build("published")?.language).toBe("en");
|
|
1366
|
+
|
|
1367
|
+
const rows = Array.from(
|
|
1368
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
1369
|
+
".compose-language-popover .compose-sheet-row",
|
|
1370
|
+
),
|
|
1371
|
+
);
|
|
1372
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
1373
|
+
await flushUpdates(el);
|
|
1374
|
+
|
|
1375
|
+
expect(build("published")?.language).toBe("zh-Hans");
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("keeps a published post's own language when editing it", async () => {
|
|
1379
|
+
// A post that is already filed in Japanese must not be re-read from the
|
|
1380
|
+
// English page it is being edited on — the language is a fact about the
|
|
1381
|
+
// post, not a guess to redo.
|
|
1382
|
+
mockEditPost({
|
|
1383
|
+
format: "note",
|
|
1384
|
+
title: "Hi",
|
|
1385
|
+
body: null,
|
|
1386
|
+
language: "ja",
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
const el = await createElement();
|
|
1390
|
+
el.languages = [
|
|
1391
|
+
{ tag: "en", label: "English" },
|
|
1392
|
+
{ tag: "ja", label: "日本語" },
|
|
1393
|
+
];
|
|
1394
|
+
el.contextLanguage = "en";
|
|
1395
|
+
await flushUpdates(el);
|
|
1396
|
+
await el.openEdit("pst_123");
|
|
1397
|
+
await flushUpdates(el);
|
|
1398
|
+
|
|
1399
|
+
const trigger = requireElement(
|
|
1400
|
+
languageTrigger(el),
|
|
1401
|
+
"expected language pill",
|
|
1402
|
+
);
|
|
1403
|
+
expect(trigger.textContent?.trim()).toBe("日本語");
|
|
1404
|
+
expect(trigger.dataset.chosen).toBe("true");
|
|
1405
|
+
|
|
1406
|
+
const build = (
|
|
1407
|
+
el as unknown as {
|
|
1408
|
+
_buildSubmitDetail: (
|
|
1409
|
+
status: "published" | "draft",
|
|
1410
|
+
) => ComposeSubmitDetail | null;
|
|
1411
|
+
}
|
|
1412
|
+
)._buildSubmitDetail.bind(el);
|
|
1413
|
+
expect(build("published")?.language).toBe("ja");
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it("falls back to the primary language when the page has none", async () => {
|
|
1417
|
+
const el = await createElement();
|
|
1418
|
+
el.languages = [
|
|
1419
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1420
|
+
{ tag: "en", label: "English" },
|
|
1421
|
+
];
|
|
1422
|
+
await flushUpdates(el);
|
|
1423
|
+
|
|
1424
|
+
const build = (
|
|
1425
|
+
el as unknown as {
|
|
1426
|
+
_buildSubmitDetail: (
|
|
1427
|
+
status: "published" | "draft",
|
|
1428
|
+
) => ComposeSubmitDetail | null;
|
|
1429
|
+
}
|
|
1430
|
+
)._buildSubmitDetail.bind(el);
|
|
1431
|
+
|
|
1432
|
+
expect(build("published")?.language).toBe("zh-Hans");
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
describe("publishing on automatic", () => {
|
|
1436
|
+
/** A composer whose text reads as a different language than the page. */
|
|
1437
|
+
async function createMismatch(overrides?: {
|
|
1438
|
+
contextLanguage?: string;
|
|
1439
|
+
text?: string;
|
|
1440
|
+
}) {
|
|
1441
|
+
const el = await createElement();
|
|
1442
|
+
el.languages = [
|
|
1443
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1444
|
+
{ tag: "ja", label: "日本語" },
|
|
1445
|
+
];
|
|
1446
|
+
el.contextLanguage = overrides?.contextLanguage ?? "zh-Hans";
|
|
1447
|
+
await flushUpdates(el);
|
|
1448
|
+
|
|
1449
|
+
const editor = requireElement(
|
|
1450
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1451
|
+
"expected compose editor",
|
|
1452
|
+
);
|
|
1453
|
+
editor._bodyJson = {
|
|
1454
|
+
type: "doc",
|
|
1455
|
+
content: [
|
|
1456
|
+
{
|
|
1457
|
+
type: "paragraph",
|
|
1458
|
+
content: [
|
|
1459
|
+
{ type: "text", text: overrides?.text ?? "これはテストです" },
|
|
1460
|
+
],
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
};
|
|
1464
|
+
await editor.updateComplete;
|
|
1465
|
+
await flushUpdates(el);
|
|
1466
|
+
|
|
1467
|
+
const details: ComposeSubmitDetail[] = [];
|
|
1468
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
1469
|
+
details.push((event as CustomEvent<ComposeSubmitDetail>).detail);
|
|
1470
|
+
});
|
|
1471
|
+
return { el, details };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function clickPublish(el: JantComposeDialog) {
|
|
1475
|
+
requireElement(
|
|
1476
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
1477
|
+
"expected post button",
|
|
1478
|
+
).click();
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
it("publishes what the pill says, without stopping to ask", async () => {
|
|
1482
|
+
// The pill grew the word 日本語 the moment detection moved off the
|
|
1483
|
+
// page's language — the same moment a sheet used to interrupt. There
|
|
1484
|
+
// is nothing left for one to add.
|
|
1485
|
+
const { el, details } = await createMismatch();
|
|
1486
|
+
expect(
|
|
1487
|
+
el.querySelector(".compose-language-trigger")?.textContent?.trim(),
|
|
1488
|
+
).toBe("日本語");
|
|
1489
|
+
|
|
1490
|
+
clickPublish(el);
|
|
1491
|
+
await flushUpdates(el);
|
|
1492
|
+
|
|
1493
|
+
expect(details).toHaveLength(1);
|
|
1494
|
+
expect(details[0]?.language).toBe("ja");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("publishes in the page's language when detection agrees", async () => {
|
|
1498
|
+
const { el, details } = await createMismatch({ contextLanguage: "ja" });
|
|
1499
|
+
|
|
1500
|
+
clickPublish(el);
|
|
1501
|
+
await flushUpdates(el);
|
|
1502
|
+
|
|
1503
|
+
expect(details).toHaveLength(1);
|
|
1504
|
+
expect(details[0]?.language).toBe("ja");
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("never second-guesses an explicit choice", async () => {
|
|
1508
|
+
const { el, details } = await createMismatch();
|
|
1509
|
+
// The author picked 简体中文 themselves; the Japanese text is theirs
|
|
1510
|
+
// to own — a quote, perhaps.
|
|
1511
|
+
(el as unknown as { _language: string | null })._language = "zh-Hans";
|
|
1512
|
+
await flushUpdates(el);
|
|
1513
|
+
|
|
1514
|
+
clickPublish(el);
|
|
1515
|
+
await flushUpdates(el);
|
|
1516
|
+
|
|
1517
|
+
expect(details).toHaveLength(1);
|
|
1518
|
+
expect(details[0]?.language).toBe("zh-Hans");
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it("keeps date and permalink on the post, not in the publish panel", async () => {
|
|
1524
|
+
const el = await createElement();
|
|
1525
|
+
|
|
1526
|
+
// They describe one post, so they hang off the post — not the publish
|
|
1527
|
+
// panel, which speaks for the whole submission.
|
|
1528
|
+
requireElement(
|
|
1529
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1530
|
+
"expected options trigger",
|
|
1531
|
+
).click();
|
|
1532
|
+
await flushUpdates(el);
|
|
1533
|
+
expect(el.querySelector(".compose-publish-date-input")).toBeNull();
|
|
1534
|
+
expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
|
|
1535
|
+
|
|
1536
|
+
// The pill states both values while the panel is shut.
|
|
1537
|
+
expect(pillValue(el, "date")).toBe("Now");
|
|
1538
|
+
expect(pillValue(el, "slug")).toBe("Auto");
|
|
1539
|
+
|
|
1540
|
+
// Two settings is not a list to drill through: opening the pill opens both
|
|
1541
|
+
// fields at once, with focus on the panel so Tab lands in them.
|
|
1542
|
+
await openPostMeta(el);
|
|
1543
|
+
expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
|
|
1544
|
+
expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
|
|
1545
|
+
expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
|
|
1546
|
+
document.activeElement,
|
|
1547
|
+
);
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
it("pins the date panel to its pill instead of inside the scroller", async () => {
|
|
1551
|
+
const el = await createElement();
|
|
1552
|
+
|
|
1553
|
+
// The pill lives in `.compose-scroll`, which clips and scrolls. An
|
|
1554
|
+
// absolutely positioned panel is taller than that box on an empty post, so
|
|
1555
|
+
// it lost its footer, and focusing it scrolled the format switcher off the
|
|
1556
|
+
// top to bring it into view. Fixed positioning leaves the scroller behind,
|
|
1557
|
+
// which makes placing the panel our job.
|
|
1558
|
+
const focusCalls: Array<{
|
|
1559
|
+
target: unknown;
|
|
1560
|
+
options?: globalThis.FocusOptions;
|
|
1561
|
+
}> = [];
|
|
1562
|
+
const nativeFocus = globalThis.HTMLElement.prototype.focus;
|
|
1563
|
+
globalThis.HTMLElement.prototype.focus = function (
|
|
1564
|
+
this: globalThis.HTMLElement,
|
|
1565
|
+
options?: globalThis.FocusOptions,
|
|
1566
|
+
) {
|
|
1567
|
+
focusCalls.push({ target: this, options });
|
|
1568
|
+
return nativeFocus.call(this, options);
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
try {
|
|
1572
|
+
await openPostMeta(el);
|
|
1573
|
+
} finally {
|
|
1574
|
+
globalThis.HTMLElement.prototype.focus = nativeFocus;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const panel = requireElement(
|
|
1578
|
+
el.querySelector<globalThis.HTMLElement>(
|
|
1579
|
+
"[data-compose-post-meta-panel]",
|
|
1580
|
+
),
|
|
1581
|
+
"expected post meta panel",
|
|
1582
|
+
);
|
|
1583
|
+
|
|
1584
|
+
expect(focusCalls.find((call) => call.target === panel)?.options).toEqual({
|
|
1585
|
+
preventScroll: true,
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// Placed under the pill, and capped at the room the viewport actually has
|
|
1589
|
+
// so a short window scrolls the panel rather than cropping it.
|
|
1590
|
+
expect(panel.style.top).toBe("6px");
|
|
1591
|
+
expect(panel.style.left).not.toBe("");
|
|
1592
|
+
expect(
|
|
1593
|
+
panel.style.getPropertyValue("--compose-post-meta-panel-max-height"),
|
|
1594
|
+
).toBe(`${globalThis.innerHeight - 12 - 6}px`);
|
|
1595
|
+
|
|
1596
|
+
// Inline coordinates only mean anything if the panel is out of the
|
|
1597
|
+
// scroller — `position: absolute` here is the bug, not a style choice.
|
|
1598
|
+
const css = readComponentCss();
|
|
1599
|
+
expect(css).toMatch(
|
|
1600
|
+
/\.compose-post-meta-panel\s*\{\s*position:\s*fixed;[\s\S]*max-height:\s*var\(--compose-post-meta-panel-max-height,\s*none\);\s*overflow-y:\s*auto;/,
|
|
1601
|
+
);
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it("keeps the date panel on its pill when the composer scrolls", async () => {
|
|
1605
|
+
const el = await createElement();
|
|
1606
|
+
await openPostMeta(el);
|
|
1607
|
+
|
|
1608
|
+
const panel = requireElement(
|
|
1609
|
+
el.querySelector<globalThis.HTMLElement>(
|
|
1610
|
+
"[data-compose-post-meta-panel]",
|
|
1611
|
+
),
|
|
1612
|
+
"expected post meta panel",
|
|
1613
|
+
);
|
|
1614
|
+
const pill = requireElement(
|
|
1615
|
+
el.querySelector<globalThis.HTMLElement>("[data-compose-post-meta-pill]"),
|
|
1616
|
+
"expected post meta pill",
|
|
1617
|
+
);
|
|
1618
|
+
pill.getBoundingClientRect = () =>
|
|
1619
|
+
({
|
|
1620
|
+
top: 100,
|
|
1621
|
+
bottom: 130,
|
|
1622
|
+
left: 300,
|
|
1623
|
+
right: 400,
|
|
1624
|
+
width: 100,
|
|
1625
|
+
height: 30,
|
|
1626
|
+
}) as globalThis.DOMRect;
|
|
1627
|
+
|
|
1628
|
+
// A `scroll` event on the composer's own box never reaches `window` — it
|
|
1629
|
+
// doesn't bubble — so a pinned panel would hang in place while the pill it
|
|
1630
|
+
// belongs to slid away underneath it.
|
|
1631
|
+
const scroller = requireElement(
|
|
1632
|
+
el.querySelector(".compose-scroll"),
|
|
1633
|
+
"expected compose scroller",
|
|
1634
|
+
);
|
|
1635
|
+
scroller.dispatchEvent(new globalThis.Event("scroll"));
|
|
1636
|
+
|
|
1637
|
+
expect(panel.style.top).toBe("136px");
|
|
1638
|
+
expect(panel.style.left).toBe("400px");
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it("opens the calendar from its own button, not the UA glyph", async () => {
|
|
1642
|
+
const el = await createElement();
|
|
1643
|
+
await openPostMeta(el);
|
|
1644
|
+
|
|
1645
|
+
const input = requireElement(
|
|
1646
|
+
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1647
|
+
"expected publish date input",
|
|
1648
|
+
);
|
|
1649
|
+
// Chrome only opens the picker from its ~13px indicator, which is hidden
|
|
1650
|
+
// here — the button has to drive the same picker itself.
|
|
1651
|
+
const showPicker = vi.fn();
|
|
1652
|
+
(input as HTMLInputElement & { showPicker: () => void }).showPicker =
|
|
1653
|
+
showPicker;
|
|
1654
|
+
|
|
1655
|
+
requireElement(
|
|
1656
|
+
el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
|
|
1657
|
+
"expected date picker button",
|
|
1658
|
+
).click();
|
|
1659
|
+
await flushUpdates(el);
|
|
1660
|
+
|
|
1661
|
+
expect(showPicker).toHaveBeenCalledTimes(1);
|
|
1662
|
+
expect(input).toBe(document.activeElement);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
it("keeps the date field usable when showPicker is unsupported", async () => {
|
|
1666
|
+
const el = await createElement();
|
|
1667
|
+
await openPostMeta(el);
|
|
1668
|
+
|
|
1669
|
+
const input = requireElement(
|
|
1670
|
+
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1671
|
+
"expected publish date input",
|
|
1672
|
+
);
|
|
1673
|
+
// Engines without `showPicker` must still get a focused, typable field
|
|
1674
|
+
// rather than an uncaught TypeError from the button.
|
|
1675
|
+
expect(
|
|
1676
|
+
(input as HTMLInputElement & { showPicker?: () => void }).showPicker,
|
|
1677
|
+
).toBeUndefined();
|
|
1678
|
+
|
|
1679
|
+
expect(() =>
|
|
1680
|
+
requireElement(
|
|
1681
|
+
el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
|
|
1682
|
+
"expected date picker button",
|
|
1683
|
+
).click(),
|
|
1684
|
+
).not.toThrow();
|
|
1685
|
+
await flushUpdates(el);
|
|
1686
|
+
|
|
1687
|
+
expect(input).toBe(document.activeElement);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
it("closes the date panel from its Done button", async () => {
|
|
1691
|
+
const el = await createElement();
|
|
1692
|
+
await openPostMeta(el);
|
|
1693
|
+
|
|
1694
|
+
// Escape and an outside click both close it, but neither announces itself,
|
|
1695
|
+
// and touch has no Escape key at all.
|
|
1696
|
+
requireElement(
|
|
1697
|
+
el.querySelector<HTMLButtonElement>("[data-compose-post-meta-done]"),
|
|
1698
|
+
"expected Done button",
|
|
1699
|
+
).click();
|
|
1700
|
+
await flushUpdates(el);
|
|
1701
|
+
|
|
1702
|
+
expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
|
|
1703
|
+
expect(el.querySelector(".compose-publish-date-input")).toBeNull();
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
it("hands focus back to the post when the date panel closes", async () => {
|
|
1707
|
+
const el = await createElement();
|
|
1708
|
+
await openPostMeta(el);
|
|
1709
|
+
expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
|
|
1710
|
+
document.activeElement,
|
|
1711
|
+
);
|
|
1712
|
+
|
|
1713
|
+
// The panel holds focus while it is open, so it would take focus out of the
|
|
1714
|
+
// document with it on the way out. The pill is only visible on hover, focus
|
|
1715
|
+
// or a non-default value — and on touch there is no hover — so losing focus
|
|
1716
|
+
// to `<body>` hides the only way back in.
|
|
1717
|
+
const editor = requireElement(
|
|
1718
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1719
|
+
"expected editor",
|
|
1720
|
+
);
|
|
1721
|
+
const focusInput = vi.spyOn(editor, "focusInput");
|
|
1722
|
+
|
|
1723
|
+
requireElement(
|
|
1724
|
+
el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
|
|
1725
|
+
"expected post meta pill",
|
|
1726
|
+
).click();
|
|
1727
|
+
await flushUpdates(el);
|
|
1728
|
+
|
|
1729
|
+
expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
|
|
1730
|
+
expect(focusInput).toHaveBeenCalled();
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
it("explains every visibility option and checks the selected one", async () => {
|
|
1734
|
+
const el = await createElement();
|
|
1735
|
+
|
|
1736
|
+
requireElement(
|
|
1737
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1738
|
+
"expected publish settings toggle",
|
|
1739
|
+
).click();
|
|
1740
|
+
await el.updateComplete;
|
|
1741
|
+
|
|
1742
|
+
// Each row carries its own hint, so the options can be compared before
|
|
1743
|
+
// one is picked — a single hint under a chip group could not do that.
|
|
1744
|
+
expect(el.textContent).toContain("Appears in Latest.");
|
|
1745
|
+
expect(el.textContent).toContain("Only visible when signed in.");
|
|
1746
|
+
|
|
1747
|
+
const rows = el.querySelectorAll<HTMLButtonElement>(
|
|
1748
|
+
".compose-sheet-row[role='radio']",
|
|
1749
|
+
);
|
|
1750
|
+
expect(rows).toHaveLength(3);
|
|
1751
|
+
expect(rows[0].getAttribute("aria-checked")).toBe("true");
|
|
1752
|
+
expect(rows[0].querySelector(".compose-sheet-check")).not.toBeNull();
|
|
1753
|
+
expect(rows[2].querySelector(".compose-sheet-check")).toBeNull();
|
|
1754
|
+
|
|
1755
|
+
rows[2].click();
|
|
1756
|
+
await flushUpdates(el);
|
|
1757
|
+
|
|
1758
|
+
// Picking is the whole job of this panel, so it dismisses itself — no
|
|
1759
|
+
// Done button to confirm a radio selection.
|
|
1760
|
+
expect(el._visibility).toBe("private");
|
|
1761
|
+
expect(el.querySelector(".compose-sheet")).toBeNull();
|
|
1762
|
+
expect(el.querySelector(".compose-publish-done")).toBeNull();
|
|
1763
|
+
|
|
1764
|
+
// Reopening shows the new choice checked.
|
|
1765
|
+
requireElement(
|
|
1766
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1767
|
+
"expected publish settings toggle",
|
|
1768
|
+
).click();
|
|
1769
|
+
await flushUpdates(el);
|
|
1770
|
+
const updated = el.querySelectorAll<HTMLButtonElement>(
|
|
1771
|
+
".compose-sheet-row[role='radio']",
|
|
1772
|
+
);
|
|
1773
|
+
expect(updated[2].getAttribute("aria-checked")).toBe("true");
|
|
1774
|
+
expect(updated[2].querySelector(".compose-sheet-check")).not.toBeNull();
|
|
1775
|
+
expect(updated[0].querySelector(".compose-sheet-check")).toBeNull();
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
it("format switching updates active state", async () => {
|
|
1779
|
+
const el = await createElement();
|
|
1780
|
+
|
|
1781
|
+
// Note is active by default
|
|
1782
|
+
const noteBtn = el.querySelectorAll<HTMLButtonElement>(
|
|
1783
|
+
".compose-segmented-item",
|
|
1784
|
+
)[0];
|
|
1785
|
+
expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
|
|
1786
|
+
true,
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
// Click link
|
|
1790
|
+
const linkBtn = el.querySelectorAll<HTMLButtonElement>(
|
|
1791
|
+
".compose-segmented-item",
|
|
1792
|
+
)[1];
|
|
1793
|
+
linkBtn.click();
|
|
1794
|
+
await el.updateComplete;
|
|
1795
|
+
|
|
1796
|
+
expect(el._format).toBe("link");
|
|
1797
|
+
expect(linkBtn.classList.contains("compose-segmented-item-active")).toBe(
|
|
1798
|
+
true,
|
|
1799
|
+
);
|
|
1800
|
+
expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
|
|
1801
|
+
false,
|
|
1802
|
+
);
|
|
1803
|
+
expect(
|
|
1804
|
+
el.querySelector('.compose-tool-btn-view[aria-label="Fullscreen"]'),
|
|
1805
|
+
).toBeNull();
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it("autofocuses the primary field on desktop format switches", async () => {
|
|
1809
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
1810
|
+
cb(0);
|
|
1811
|
+
return 1;
|
|
1812
|
+
});
|
|
1813
|
+
const el = await createElement();
|
|
1814
|
+
const editor = requireElement(
|
|
1815
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1816
|
+
"expected compose editor",
|
|
1817
|
+
);
|
|
1818
|
+
const focusSpy = vi.spyOn(editor, "focusInput");
|
|
1819
|
+
|
|
1820
|
+
const linkBtn = el.querySelectorAll<HTMLButtonElement>(
|
|
1821
|
+
".compose-segmented-item",
|
|
1822
|
+
)[1];
|
|
1823
|
+
linkBtn.click();
|
|
1824
|
+
await flushUpdates(el);
|
|
1825
|
+
|
|
1826
|
+
expect(focusSpy).toHaveBeenCalledTimes(1);
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it("does not autofocus the primary field on touch format switches", async () => {
|
|
1830
|
+
mockMatchMedia(true);
|
|
1831
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
1832
|
+
cb(0);
|
|
1833
|
+
return 1;
|
|
1834
|
+
});
|
|
1835
|
+
const el = await createElement();
|
|
1836
|
+
const editor = requireElement(
|
|
1837
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1838
|
+
"expected compose editor",
|
|
1839
|
+
);
|
|
1840
|
+
const focusSpy = vi.spyOn(editor, "focusInput");
|
|
1841
|
+
|
|
1842
|
+
const linkBtn = el.querySelectorAll<HTMLButtonElement>(
|
|
1843
|
+
".compose-segmented-item",
|
|
1844
|
+
)[1];
|
|
1845
|
+
linkBtn.click();
|
|
1846
|
+
await flushUpdates(el);
|
|
1847
|
+
|
|
1848
|
+
expect(focusSpy).not.toHaveBeenCalled();
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
function mockEditPost(post: Record<string, unknown>) {
|
|
1852
|
+
return vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
1853
|
+
new Response(JSON.stringify({ id: "pst_123", ...post }), {
|
|
1854
|
+
headers: { "Content-Type": "application/json" },
|
|
1855
|
+
}),
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
it("shows the format switcher above the post while editing", async () => {
|
|
1860
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
1861
|
+
cb(0);
|
|
1862
|
+
return 1;
|
|
1863
|
+
});
|
|
1864
|
+
mockEditPost({ format: "note", title: "Hello", body: null });
|
|
1865
|
+
|
|
1866
|
+
const el = await createElement();
|
|
1867
|
+
await el.openEdit("pst_123");
|
|
1868
|
+
await flushUpdates(el);
|
|
1869
|
+
|
|
1870
|
+
// The switcher has exactly one home: above its own post. Editing says so
|
|
1871
|
+
// through the submit label and a marker in the header row, not a title bar.
|
|
1872
|
+
expect(el.querySelector(".compose-thread-post-header")).toBeTruthy();
|
|
1873
|
+
expect(el.querySelector(".compose-dialog-header")).toBeNull();
|
|
1874
|
+
expect(
|
|
1875
|
+
el
|
|
1876
|
+
.querySelector(".compose-thread-post-header .compose-post-badge")
|
|
1877
|
+
?.textContent?.trim(),
|
|
1878
|
+
).toBe(labels.editing);
|
|
1879
|
+
expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
|
|
1880
|
+
labels.update,
|
|
1881
|
+
);
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
it("shows the format selector above the post when replying", async () => {
|
|
1885
|
+
const el = await createElement();
|
|
1886
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
1887
|
+
contentHtml: "<p>Parent</p>",
|
|
1888
|
+
dateText: "Mar 14",
|
|
1889
|
+
});
|
|
1890
|
+
await flushUpdates(el);
|
|
1891
|
+
|
|
1892
|
+
expect(el.querySelector(".compose-dialog-header")).toBeNull();
|
|
1893
|
+
// The selector sits inline above the reply editor.
|
|
1894
|
+
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
1895
|
+
expect(
|
|
1896
|
+
el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
|
|
1897
|
+
).not.toBeNull();
|
|
1898
|
+
expect(el.querySelector(".compose-collection-trigger")).toBeNull();
|
|
1899
|
+
expect(
|
|
1900
|
+
el.querySelector(".compose-action-row-without-collection"),
|
|
1901
|
+
).not.toBeNull();
|
|
1902
|
+
expect(el.querySelector(".compose-publish-main")).not.toBeNull();
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
it("puts a reply's add-to-thread row on the rail with the other posts", async () => {
|
|
1906
|
+
const el = await createElement();
|
|
1907
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
1908
|
+
contentHtml: "<p>Parent</p>",
|
|
1909
|
+
dateText: "Mar 14",
|
|
1910
|
+
});
|
|
1911
|
+
await flushUpdates(el);
|
|
1912
|
+
|
|
1913
|
+
// Inside the layout, not a sibling of it: out there it misses the rail's
|
|
1914
|
+
// indent and lands a rail's width left of everything above it.
|
|
1915
|
+
const layout = el.querySelector(
|
|
1916
|
+
".compose-thread-layout.compose-reply-compose-layout",
|
|
1917
|
+
);
|
|
1918
|
+
const addRow = layout?.querySelector(":scope > .compose-thread-add-row");
|
|
1919
|
+
expect(addRow).not.toBeNull();
|
|
1920
|
+
expect(el.querySelector(".compose-add-thread-trigger")).toBeNull();
|
|
1921
|
+
// The dashed dot marks where the next post lands, as in thread compose.
|
|
1922
|
+
expect(addRow?.querySelector(".compose-thread-add-dot")).not.toBeNull();
|
|
1923
|
+
// ...and it stays the last row, so the rail still stops at the reply.
|
|
1924
|
+
expect(layout?.lastElementChild).toBe(addRow);
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
it("keeps a single post's add-to-thread row off the rail", async () => {
|
|
1928
|
+
const el = await createElement();
|
|
1929
|
+
await flushUpdates(el);
|
|
1930
|
+
|
|
1931
|
+
// No rail to join, so no dot: the row's position says "next slot" alone.
|
|
1932
|
+
const trigger = el.querySelector(".compose-add-thread-trigger");
|
|
1933
|
+
expect(trigger).not.toBeNull();
|
|
1934
|
+
expect(el.querySelector(".compose-thread-add-dot")).toBeNull();
|
|
1935
|
+
expect(trigger?.querySelector(".compose-thread-add-btn")).not.toBeNull();
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it("shows an Edit title with the format selector above the post when editing a reply", async () => {
|
|
1939
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
1940
|
+
cb(0);
|
|
1941
|
+
return 1;
|
|
1942
|
+
});
|
|
1943
|
+
const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
|
|
1944
|
+
// A fresh Response per call: openEdit reads the edited post, then
|
|
1945
|
+
// _fetchReplyContext reads the parent — a single shared Response body can
|
|
1946
|
+
// only be consumed once.
|
|
1947
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
|
1948
|
+
const url = String(input);
|
|
1949
|
+
const json = url.includes(parentId)
|
|
1950
|
+
? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
|
|
1951
|
+
: {
|
|
1952
|
+
id: "pst_123",
|
|
1953
|
+
format: "note",
|
|
1954
|
+
title: "Hello",
|
|
1955
|
+
body: null,
|
|
1956
|
+
replyToId: parentId,
|
|
1957
|
+
collectionIds: ["col-thread"],
|
|
1958
|
+
};
|
|
1959
|
+
return Promise.resolve(
|
|
1960
|
+
new Response(JSON.stringify(json), {
|
|
1961
|
+
headers: { "Content-Type": "application/json" },
|
|
1962
|
+
}),
|
|
1963
|
+
);
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
const el = await createElement();
|
|
1967
|
+
await el.openEdit("pst_123");
|
|
1968
|
+
await flushUpdates(el);
|
|
1969
|
+
|
|
1970
|
+
expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
|
|
1971
|
+
labels.update,
|
|
1972
|
+
);
|
|
1973
|
+
expect(el.querySelector(".compose-dialog-header")).toBeNull();
|
|
1974
|
+
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
1975
|
+
expect(el._collectionIds).toEqual([]);
|
|
1976
|
+
expect(el.querySelector(".compose-collection-trigger")).toBeNull();
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
it("keeps the format selector above the post in single-post mode", async () => {
|
|
1980
|
+
const el = await createElement();
|
|
1981
|
+
await flushUpdates(el);
|
|
1982
|
+
|
|
1983
|
+
// One selector, one place — above its own post.
|
|
1984
|
+
const items = el.querySelectorAll<HTMLButtonElement>(
|
|
1985
|
+
".compose-thread-post-header .compose-segmented-item",
|
|
1986
|
+
);
|
|
1987
|
+
expect(items.length).toBe(3);
|
|
1988
|
+
|
|
1989
|
+
items[1].click(); // link
|
|
1990
|
+
await flushUpdates(el);
|
|
1991
|
+
expect(el._format).toBe("link");
|
|
1992
|
+
|
|
1993
|
+
// A single post has no "which one", so no position marker.
|
|
1994
|
+
expect(el.querySelector(".compose-post-position")).toBeNull();
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
it("gives every thread post its own date and permalink control", async () => {
|
|
1998
|
+
const el = await createElement();
|
|
1999
|
+
el._threadItems = [
|
|
2000
|
+
{ id: "thread-1", format: "note" },
|
|
2001
|
+
{ id: "thread-2", format: "note" },
|
|
2002
|
+
];
|
|
2003
|
+
await flushUpdates(el);
|
|
2004
|
+
|
|
2005
|
+
// Every post is separately addressable (`path_registry` has a row each),
|
|
2006
|
+
// so every post gets both controls — not just the root.
|
|
2007
|
+
const pills = el.querySelectorAll("[data-compose-post-meta-pill]");
|
|
2008
|
+
expect(pills).toHaveLength(2);
|
|
2009
|
+
for (const pill of pills) {
|
|
2010
|
+
expect(
|
|
2011
|
+
pill.querySelector(".compose-post-meta-value-slug"),
|
|
2012
|
+
).not.toBeNull();
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
it("sends a reply's own permalink and blocks an invalid one", async () => {
|
|
2017
|
+
const el = await createElement();
|
|
2018
|
+
el._threadItems = [
|
|
2019
|
+
{ id: "thread-1", format: "note" },
|
|
2020
|
+
{ id: "thread-2", format: "note", slug: "part-two" },
|
|
2021
|
+
];
|
|
2022
|
+
await flushUpdates(el);
|
|
2023
|
+
|
|
2024
|
+
const build = (
|
|
2025
|
+
el as unknown as {
|
|
2026
|
+
_buildEditorPostDetail: (
|
|
2027
|
+
editor: JantComposeEditor,
|
|
2028
|
+
format: string,
|
|
2029
|
+
index: number,
|
|
2030
|
+
status: string,
|
|
2031
|
+
) => ComposeSubmitDetail;
|
|
2032
|
+
}
|
|
2033
|
+
)._buildEditorPostDetail;
|
|
2034
|
+
const editors = el.querySelectorAll<JantComposeEditor>(
|
|
2035
|
+
"jant-compose-editor",
|
|
2036
|
+
);
|
|
2037
|
+
|
|
2038
|
+
expect(build.call(el, editors[1], "note", 1, "published").slug).toBe(
|
|
2039
|
+
"part-two",
|
|
2040
|
+
);
|
|
2041
|
+
expect(build.call(el, editors[0], "note", 0, "published").slug).toBe(
|
|
2042
|
+
undefined,
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
// A reply's bad slug blocks publish just like the root's would.
|
|
2046
|
+
el._threadItems = [
|
|
2047
|
+
{ id: "thread-1", format: "note" },
|
|
2048
|
+
{ id: "thread-2", format: "note", slug: "not a slug!" },
|
|
2049
|
+
];
|
|
2050
|
+
await flushUpdates(el);
|
|
2051
|
+
expect(
|
|
2052
|
+
(el as unknown as { _canPublish: () => boolean })._canPublish(),
|
|
2053
|
+
).toBe(false);
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it("sends a reply's own date and leaves the rest for the server to inherit", async () => {
|
|
2057
|
+
const el = await createElement();
|
|
2058
|
+
el._threadItems = [
|
|
2059
|
+
{ id: "thread-1", format: "note" },
|
|
2060
|
+
{ id: "thread-2", format: "note", publishedAtInput: "2024-03-09" },
|
|
2061
|
+
{ id: "thread-3", format: "note" },
|
|
2062
|
+
];
|
|
2063
|
+
await flushUpdates(el);
|
|
2064
|
+
|
|
2065
|
+
const detail = (
|
|
2066
|
+
el as unknown as {
|
|
2067
|
+
_buildEditorPostDetail: (
|
|
2068
|
+
editor: JantComposeEditor,
|
|
2069
|
+
format: string,
|
|
2070
|
+
index: number,
|
|
2071
|
+
status: string,
|
|
2072
|
+
) => ComposeSubmitDetail;
|
|
2073
|
+
}
|
|
2074
|
+
)._buildEditorPostDetail;
|
|
2075
|
+
const editors = el.querySelectorAll<JantComposeEditor>(
|
|
2076
|
+
"jant-compose-editor",
|
|
2077
|
+
);
|
|
2078
|
+
|
|
2079
|
+
const second = detail.call(el, editors[1], "note", 1, "published");
|
|
2080
|
+
const third = detail.call(el, editors[2], "note", 2, "published");
|
|
2081
|
+
|
|
2082
|
+
expect(second.publishedAt).toBeGreaterThan(0);
|
|
2083
|
+
// Undefined, not "now" — the server fills it from the root.
|
|
2084
|
+
expect(third.publishedAt).toBeUndefined();
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
it("blocks publishing when any thread post has a future date", async () => {
|
|
2088
|
+
const el = await createElement();
|
|
2089
|
+
const future = new Date(Date.now() + 86400000 * 3)
|
|
2090
|
+
.toISOString()
|
|
2091
|
+
.slice(0, 10);
|
|
2092
|
+
el._threadItems = [
|
|
2093
|
+
{ id: "thread-1", format: "note" },
|
|
2094
|
+
{ id: "thread-2", format: "note", publishedAtInput: future },
|
|
2095
|
+
];
|
|
2096
|
+
await flushUpdates(el);
|
|
2097
|
+
|
|
2098
|
+
expect(
|
|
2099
|
+
(el as unknown as { _canPublish: () => boolean })._canPublish(),
|
|
2100
|
+
).toBe(false);
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
/**
|
|
2104
|
+
* Publish reads each row's own answer, so a row that has been removed stops
|
|
2105
|
+
* being asked. These used to fail: the button was computed during render from
|
|
2106
|
+
* the editors still in the DOM — including the one that render was about to
|
|
2107
|
+
* remove.
|
|
2108
|
+
*/
|
|
2109
|
+
describe("publish reflects the rows that are left", () => {
|
|
2110
|
+
const publishButton = (el: JantComposeDialog) =>
|
|
2111
|
+
requireElement(
|
|
2112
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
2113
|
+
"expected publish button",
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
async function typeInto(
|
|
2117
|
+
el: JantComposeDialog,
|
|
2118
|
+
index: number,
|
|
2119
|
+
text: string,
|
|
2120
|
+
) {
|
|
2121
|
+
const editor = el.querySelectorAll<JantComposeEditor>(
|
|
2122
|
+
"jant-compose-editor",
|
|
2123
|
+
)[index];
|
|
2124
|
+
requireEditor(
|
|
2125
|
+
requireElement(editor, `expected editor ${index}`),
|
|
2126
|
+
).commands.insertContent(text);
|
|
2127
|
+
await flushUpdates(el);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
async function addPost(el: JantComposeDialog) {
|
|
2131
|
+
requireElement(
|
|
2132
|
+
el.querySelector<HTMLButtonElement>(".compose-thread-add-btn"),
|
|
2133
|
+
"expected add-to-thread button",
|
|
2134
|
+
).click();
|
|
2135
|
+
await flushUpdates(el);
|
|
2136
|
+
await flushUpdates(el);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
async function removePost(el: JantComposeDialog, index: number) {
|
|
2140
|
+
requireElement(
|
|
2141
|
+
el.querySelectorAll<HTMLButtonElement>(".compose-thread-post-remove")[
|
|
2142
|
+
index
|
|
2143
|
+
],
|
|
2144
|
+
`expected remove button ${index}`,
|
|
2145
|
+
).click();
|
|
2146
|
+
await flushUpdates(el);
|
|
2147
|
+
await flushUpdates(el);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
it("re-enables publish when the empty last post is removed", async () => {
|
|
2151
|
+
const el = await createElement();
|
|
2152
|
+
await typeInto(el, 0, "one");
|
|
2153
|
+
await addPost(el);
|
|
2154
|
+
await typeInto(el, 1, "two");
|
|
2155
|
+
await addPost(el);
|
|
2156
|
+
|
|
2157
|
+
expect(publishButton(el).disabled).toBe(true);
|
|
547
2158
|
|
|
548
|
-
|
|
549
|
-
expect(el.textContent).not.toContain("Appears in Latest.");
|
|
550
|
-
});
|
|
2159
|
+
await removePost(el, 2);
|
|
551
2160
|
|
|
552
|
-
|
|
553
|
-
|
|
2161
|
+
expect(publishButton(el).disabled).toBe(false);
|
|
2162
|
+
});
|
|
554
2163
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
"
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
true,
|
|
561
|
-
);
|
|
2164
|
+
it("re-enables publish when the emptied post is the first of two", async () => {
|
|
2165
|
+
const el = await createElement();
|
|
2166
|
+
await typeInto(el, 0, "one");
|
|
2167
|
+
await addPost(el);
|
|
2168
|
+
await typeInto(el, 1, "two");
|
|
562
2169
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
await el.updateComplete;
|
|
2170
|
+
requireEditor(
|
|
2171
|
+
el.querySelectorAll<JantComposeEditor>("jant-compose-editor")[0],
|
|
2172
|
+
).commands.clearContent();
|
|
2173
|
+
await flushUpdates(el);
|
|
2174
|
+
expect(publishButton(el).disabled).toBe(true);
|
|
569
2175
|
|
|
570
|
-
|
|
571
|
-
expect(linkBtn.classList.contains("compose-segmented-item-active")).toBe(
|
|
572
|
-
true,
|
|
573
|
-
);
|
|
574
|
-
expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
|
|
575
|
-
false,
|
|
576
|
-
);
|
|
577
|
-
expect(
|
|
578
|
-
el.querySelector('.compose-tool-btn-view[aria-label="Fullscreen"]'),
|
|
579
|
-
).toBeNull();
|
|
580
|
-
});
|
|
2176
|
+
await removePost(el, 0);
|
|
581
2177
|
|
|
582
|
-
|
|
583
|
-
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
584
|
-
cb(0);
|
|
585
|
-
return 1;
|
|
2178
|
+
expect(publishButton(el).disabled).toBe(false);
|
|
586
2179
|
});
|
|
587
|
-
const el = await createElement();
|
|
588
|
-
const editor = requireElement(
|
|
589
|
-
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
590
|
-
"expected compose editor",
|
|
591
|
-
);
|
|
592
|
-
const focusSpy = vi.spyOn(editor, "focusInput");
|
|
593
|
-
|
|
594
|
-
const linkBtn = el.querySelectorAll<HTMLButtonElement>(
|
|
595
|
-
".compose-segmented-item",
|
|
596
|
-
)[1];
|
|
597
|
-
linkBtn.click();
|
|
598
|
-
await flushUpdates(el);
|
|
599
2180
|
|
|
600
|
-
|
|
2181
|
+
it("keeps each post's own text when the middle post is removed", async () => {
|
|
2182
|
+
const el = await createElement();
|
|
2183
|
+
await typeInto(el, 0, "AAA");
|
|
2184
|
+
await addPost(el);
|
|
2185
|
+
await typeInto(el, 1, "BBB");
|
|
2186
|
+
await addPost(el);
|
|
2187
|
+
await typeInto(el, 2, "CCC");
|
|
2188
|
+
|
|
2189
|
+
await removePost(el, 1);
|
|
2190
|
+
|
|
2191
|
+
const text = Array.from(
|
|
2192
|
+
el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
|
|
2193
|
+
).map((editor) => requireEditor(editor).getText());
|
|
2194
|
+
expect(text).toEqual(["AAA", "CCC"]);
|
|
2195
|
+
});
|
|
601
2196
|
});
|
|
602
2197
|
|
|
603
|
-
it("
|
|
604
|
-
mockMatchMedia(true);
|
|
605
|
-
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
606
|
-
cb(0);
|
|
607
|
-
return 1;
|
|
608
|
-
});
|
|
2198
|
+
it("numbers each post in a thread", async () => {
|
|
609
2199
|
const el = await createElement();
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
"
|
|
613
|
-
|
|
614
|
-
|
|
2200
|
+
el._threadItems = [
|
|
2201
|
+
{ id: "thread-1", format: "note" },
|
|
2202
|
+
{ id: "thread-2", format: "note" },
|
|
2203
|
+
];
|
|
2204
|
+
await flushUpdates(el);
|
|
615
2205
|
|
|
616
|
-
const
|
|
617
|
-
".compose-
|
|
618
|
-
|
|
619
|
-
|
|
2206
|
+
const positions = () =>
|
|
2207
|
+
Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
|
|
2208
|
+
n.textContent?.trim(),
|
|
2209
|
+
);
|
|
2210
|
+
expect(positions()).toEqual(["1/2", "2/2"]);
|
|
2211
|
+
|
|
2212
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
620
2213
|
await flushUpdates(el);
|
|
621
2214
|
|
|
622
|
-
|
|
2215
|
+
// The total re-renders on every post, not just the new one.
|
|
2216
|
+
expect(positions()).toEqual(["1/3", "2/3", "3/3"]);
|
|
623
2217
|
});
|
|
624
2218
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
2219
|
+
const positionsIn = (el: JantComposeDialog) =>
|
|
2220
|
+
Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
|
|
2221
|
+
n.textContent?.trim(),
|
|
2222
|
+
);
|
|
2223
|
+
|
|
2224
|
+
it("numbers a reply from the end of the thread it continues", async () => {
|
|
2225
|
+
// The parent is the second post of a thread, so the reply is the third —
|
|
2226
|
+
// not the first, and not the second.
|
|
2227
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
2228
|
+
new Response(JSON.stringify({ id: "pst_parent", threadPosition: 2 }), {
|
|
628
2229
|
headers: { "Content-Type": "application/json" },
|
|
629
2230
|
}),
|
|
630
2231
|
);
|
|
631
|
-
}
|
|
632
2232
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
2233
|
+
const el = await createElement();
|
|
2234
|
+
await el.openReply("pst_parent", {
|
|
2235
|
+
contentHtml: "<p>Parent</p>",
|
|
2236
|
+
dateText: "Mar 14",
|
|
637
2237
|
});
|
|
638
|
-
|
|
2238
|
+
await flushUpdates(el);
|
|
2239
|
+
await flushUpdates(el);
|
|
639
2240
|
|
|
640
|
-
|
|
641
|
-
|
|
2241
|
+
expect(positionsIn(el)).toEqual(["3/3"]);
|
|
2242
|
+
|
|
2243
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
642
2244
|
await flushUpdates(el);
|
|
643
2245
|
|
|
644
|
-
expect(el.
|
|
645
|
-
// The "Edit post" title is gone — the switcher takes the center slot.
|
|
646
|
-
expect(el.querySelector(".compose-dialog-title")).toBeNull();
|
|
2246
|
+
expect(positionsIn(el)).toEqual(["3/4", "4/4"]);
|
|
647
2247
|
});
|
|
648
2248
|
|
|
649
|
-
it("shows
|
|
2249
|
+
it("shows no number for a reply until the parent's position is known", async () => {
|
|
2250
|
+
// Counting from 1 and then correcting to 3 is worse than arriving late, so
|
|
2251
|
+
// the marker stays away while the lookup is in flight — and stays away for
|
|
2252
|
+
// good if it fails.
|
|
2253
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
|
2254
|
+
|
|
650
2255
|
const el = await createElement();
|
|
651
|
-
await el.openReply("
|
|
2256
|
+
await el.openReply("pst_parent", {
|
|
652
2257
|
contentHtml: "<p>Parent</p>",
|
|
653
2258
|
dateText: "Mar 14",
|
|
654
2259
|
});
|
|
655
2260
|
await flushUpdates(el);
|
|
2261
|
+
await flushUpdates(el);
|
|
656
2262
|
|
|
657
|
-
expect(el
|
|
658
|
-
"Reply",
|
|
659
|
-
);
|
|
660
|
-
// The header center no longer hosts the format selector...
|
|
661
|
-
expect(
|
|
662
|
-
el.querySelector(".compose-dialog-header-center .compose-segmented"),
|
|
663
|
-
).toBeNull();
|
|
664
|
-
// ...it sits inline above the reply editor instead.
|
|
665
|
-
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
666
|
-
expect(
|
|
667
|
-
el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
|
|
668
|
-
).not.toBeNull();
|
|
669
|
-
expect(el.querySelector(".compose-collection-trigger")).toBeNull();
|
|
670
|
-
expect(
|
|
671
|
-
el.querySelector(".compose-action-row-without-collection"),
|
|
672
|
-
).not.toBeNull();
|
|
673
|
-
expect(el.querySelector(".compose-publish-main")).not.toBeNull();
|
|
2263
|
+
expect(positionsIn(el)).toEqual([]);
|
|
674
2264
|
});
|
|
675
2265
|
|
|
676
|
-
it("
|
|
677
|
-
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
678
|
-
cb(0);
|
|
679
|
-
return 1;
|
|
680
|
-
});
|
|
681
|
-
const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
|
|
682
|
-
// A fresh Response per call: openEdit reads the edited post, then
|
|
683
|
-
// _fetchReplyContext reads the parent — a single shared Response body can
|
|
684
|
-
// only be consumed once.
|
|
685
|
-
vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
|
686
|
-
const url = String(input);
|
|
687
|
-
const json = url.includes(parentId)
|
|
688
|
-
? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
|
|
689
|
-
: {
|
|
690
|
-
id: "pst_123",
|
|
691
|
-
format: "note",
|
|
692
|
-
title: "Hello",
|
|
693
|
-
body: null,
|
|
694
|
-
replyToId: parentId,
|
|
695
|
-
collectionIds: ["col-thread"],
|
|
696
|
-
};
|
|
697
|
-
return Promise.resolve(
|
|
698
|
-
new Response(JSON.stringify(json), {
|
|
699
|
-
headers: { "Content-Type": "application/json" },
|
|
700
|
-
}),
|
|
701
|
-
);
|
|
702
|
-
});
|
|
703
|
-
|
|
2266
|
+
it("leaves a lone new post unnumbered — there is no chain to place it in", async () => {
|
|
704
2267
|
const el = await createElement();
|
|
705
|
-
await el.openEdit("pst_123");
|
|
706
2268
|
await flushUpdates(el);
|
|
707
2269
|
|
|
708
|
-
expect(el.querySelector(".compose-
|
|
709
|
-
"Edit",
|
|
710
|
-
);
|
|
711
|
-
expect(
|
|
712
|
-
el.querySelector(".compose-dialog-header-center .compose-segmented"),
|
|
713
|
-
).toBeNull();
|
|
714
|
-
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
715
|
-
expect(el._collectionIds).toEqual([]);
|
|
716
|
-
expect(el.querySelector(".compose-collection-trigger")).toBeNull();
|
|
2270
|
+
expect(el.querySelector(".compose-post-position")).toBeNull();
|
|
717
2271
|
});
|
|
718
2272
|
|
|
719
2273
|
it("switches format from the inline selector when replying", async () => {
|
|
@@ -959,8 +2513,7 @@ describe("JantComposeDialog", () => {
|
|
|
959
2513
|
await editor.updateComplete;
|
|
960
2514
|
|
|
961
2515
|
let receivedDetail:
|
|
962
|
-
|
|
963
|
-
| null = null;
|
|
2516
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
964
2517
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
965
2518
|
const customEvent = event as CustomEvent<
|
|
966
2519
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -1040,13 +2593,13 @@ describe("JantComposeDialog", () => {
|
|
|
1040
2593
|
await editor.updateComplete;
|
|
1041
2594
|
|
|
1042
2595
|
requireElement(
|
|
1043
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2596
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1044
2597
|
"expected publish settings toggle",
|
|
1045
2598
|
).click();
|
|
1046
2599
|
await el.updateComplete;
|
|
1047
2600
|
|
|
1048
2601
|
const options = el.querySelectorAll<HTMLButtonElement>(
|
|
1049
|
-
".compose-
|
|
2602
|
+
".compose-sheet-row[role='radio']",
|
|
1050
2603
|
);
|
|
1051
2604
|
expect(options).toHaveLength(3);
|
|
1052
2605
|
options[1]?.click();
|
|
@@ -1100,11 +2653,12 @@ describe("JantComposeDialog", () => {
|
|
|
1100
2653
|
await editor.updateComplete;
|
|
1101
2654
|
|
|
1102
2655
|
requireElement(
|
|
1103
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2656
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1104
2657
|
"expected publish settings toggle",
|
|
1105
2658
|
).click();
|
|
1106
2659
|
await el.updateComplete;
|
|
1107
2660
|
|
|
2661
|
+
await openPostMeta(el);
|
|
1108
2662
|
const publishedAtInput = requireElement(
|
|
1109
2663
|
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1110
2664
|
"expected publish date input",
|
|
@@ -1145,16 +2699,17 @@ describe("JantComposeDialog", () => {
|
|
|
1145
2699
|
}
|
|
1146
2700
|
});
|
|
1147
2701
|
|
|
1148
|
-
it("
|
|
2702
|
+
it("keeps the chosen publish date readable on the post's pill", async () => {
|
|
1149
2703
|
const el = await createElement();
|
|
1150
2704
|
|
|
1151
2705
|
const publishToggle = requireElement(
|
|
1152
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2706
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1153
2707
|
"expected publish settings toggle",
|
|
1154
2708
|
);
|
|
1155
2709
|
publishToggle.click();
|
|
1156
2710
|
await el.updateComplete;
|
|
1157
2711
|
|
|
2712
|
+
await openPostMeta(el);
|
|
1158
2713
|
const publishedAtInput = requireElement(
|
|
1159
2714
|
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1160
2715
|
"expected publish date input",
|
|
@@ -1163,31 +2718,25 @@ describe("JantComposeDialog", () => {
|
|
|
1163
2718
|
publishedAtInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1164
2719
|
await el.updateComplete;
|
|
1165
2720
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
"[data-compose-publish-date-summary]",
|
|
1172
|
-
),
|
|
1173
|
-
"expected publish date summary",
|
|
1174
|
-
);
|
|
1175
|
-
expect(summary.dataset.publishDate).toBe("2024-01-15");
|
|
1176
|
-
expect(summary.textContent).toContain("2024");
|
|
1177
|
-
|
|
1178
|
-
summary.click();
|
|
1179
|
-
await el.updateComplete;
|
|
2721
|
+
// Close the panel: the chosen date stays readable on the pill.
|
|
2722
|
+
requireElement(
|
|
2723
|
+
el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
|
|
2724
|
+
"expected post meta pill",
|
|
2725
|
+
).click();
|
|
1180
2726
|
await flushUpdates(el);
|
|
2727
|
+
expect(el.querySelector(".compose-publish-date-input")).toBeNull();
|
|
2728
|
+
expect(pillValue(el, "date")).toContain("2024");
|
|
1181
2729
|
|
|
2730
|
+
await openPostMeta(el);
|
|
1182
2731
|
expect(
|
|
1183
2732
|
requireElement(
|
|
1184
2733
|
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1185
2734
|
"expected publish date input after reopening publish settings",
|
|
1186
|
-
),
|
|
1187
|
-
).toBe(
|
|
2735
|
+
).value,
|
|
2736
|
+
).toBe("2024-01-15");
|
|
1188
2737
|
});
|
|
1189
2738
|
|
|
1190
|
-
it("
|
|
2739
|
+
it("shows the existing publish date on the post pill while editing a post", async () => {
|
|
1191
2740
|
const el = await createElement();
|
|
1192
2741
|
(
|
|
1193
2742
|
el as unknown as {
|
|
@@ -1213,10 +2762,12 @@ describe("JantComposeDialog", () => {
|
|
|
1213
2762
|
el.requestUpdate();
|
|
1214
2763
|
await el.updateComplete;
|
|
1215
2764
|
|
|
1216
|
-
|
|
2765
|
+
await openPostMeta(el);
|
|
2766
|
+
|
|
2767
|
+
expect(pillValue(el, "date")).toContain("2024");
|
|
1217
2768
|
});
|
|
1218
2769
|
|
|
1219
|
-
it("shows
|
|
2770
|
+
it("shows Now on the post date control after clearing the date while editing", async () => {
|
|
1220
2771
|
const el = await createElement();
|
|
1221
2772
|
(
|
|
1222
2773
|
el as unknown as {
|
|
@@ -1242,15 +2793,12 @@ describe("JantComposeDialog", () => {
|
|
|
1242
2793
|
el.requestUpdate();
|
|
1243
2794
|
await el.updateComplete;
|
|
1244
2795
|
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
"expected publish now summary",
|
|
1249
|
-
).textContent,
|
|
1250
|
-
).toContain("Publish now");
|
|
2796
|
+
await openPostMeta(el);
|
|
2797
|
+
|
|
2798
|
+
expect(pillValue(el, "date")).toBe("Now");
|
|
1251
2799
|
});
|
|
1252
2800
|
|
|
1253
|
-
it("
|
|
2801
|
+
it("keeps the custom link readable on the post's pill", async () => {
|
|
1254
2802
|
mockSlugApi((url) => {
|
|
1255
2803
|
if (url.searchParams.get("mode") === "suggest") {
|
|
1256
2804
|
return { body: { slug: "configured-post" } };
|
|
@@ -1263,7 +2811,7 @@ describe("JantComposeDialog", () => {
|
|
|
1263
2811
|
|
|
1264
2812
|
const el = await createElement();
|
|
1265
2813
|
const publishToggle = requireElement(
|
|
1266
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2814
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1267
2815
|
"expected publish settings toggle",
|
|
1268
2816
|
);
|
|
1269
2817
|
|
|
@@ -1271,6 +2819,7 @@ describe("JantComposeDialog", () => {
|
|
|
1271
2819
|
await el.updateComplete;
|
|
1272
2820
|
await flushUpdates(el);
|
|
1273
2821
|
|
|
2822
|
+
await openPostMeta(el);
|
|
1274
2823
|
const slugInput = requireElement(
|
|
1275
2824
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1276
2825
|
"expected custom link input",
|
|
@@ -1280,31 +2829,25 @@ describe("JantComposeDialog", () => {
|
|
|
1280
2829
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1281
2830
|
await el.updateComplete;
|
|
1282
2831
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
"[data-compose-publish-slug-summary]",
|
|
1289
|
-
),
|
|
1290
|
-
"expected custom link summary",
|
|
1291
|
-
);
|
|
1292
|
-
expect(summary.dataset.publishSlug).toBe("reading-notes");
|
|
1293
|
-
expect(summary.textContent).toContain("/reading-notes");
|
|
1294
|
-
|
|
1295
|
-
summary.click();
|
|
1296
|
-
await el.updateComplete;
|
|
2832
|
+
// Close the panel: the custom link stays readable on the pill.
|
|
2833
|
+
requireElement(
|
|
2834
|
+
el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
|
|
2835
|
+
"expected post meta pill",
|
|
2836
|
+
).click();
|
|
1297
2837
|
await flushUpdates(el);
|
|
2838
|
+
expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
|
|
2839
|
+
expect(pillValue(el, "slug")).toBe("/reading-notes");
|
|
1298
2840
|
|
|
2841
|
+
await openPostMeta(el);
|
|
1299
2842
|
expect(
|
|
1300
2843
|
requireElement(
|
|
1301
2844
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1302
2845
|
"expected custom link input after reopening publish settings",
|
|
1303
|
-
),
|
|
1304
|
-
).toBe(
|
|
2846
|
+
).value,
|
|
2847
|
+
).toBe("reading-notes");
|
|
1305
2848
|
});
|
|
1306
2849
|
|
|
1307
|
-
it("
|
|
2850
|
+
it("shows the existing custom link on the post pill while editing a post", async () => {
|
|
1308
2851
|
const el = await createElement();
|
|
1309
2852
|
(
|
|
1310
2853
|
el as unknown as {
|
|
@@ -1330,10 +2873,12 @@ describe("JantComposeDialog", () => {
|
|
|
1330
2873
|
el.requestUpdate();
|
|
1331
2874
|
await el.updateComplete;
|
|
1332
2875
|
|
|
1333
|
-
|
|
2876
|
+
await openPostMeta(el);
|
|
2877
|
+
|
|
2878
|
+
expect(pillValue(el, "slug")).toBe("/reading-notes");
|
|
1334
2879
|
});
|
|
1335
2880
|
|
|
1336
|
-
it("shows
|
|
2881
|
+
it("shows Auto on the post permalink pill after clearing the link while editing", async () => {
|
|
1337
2882
|
const el = await createElement();
|
|
1338
2883
|
(
|
|
1339
2884
|
el as unknown as {
|
|
@@ -1359,12 +2904,9 @@ describe("JantComposeDialog", () => {
|
|
|
1359
2904
|
el.requestUpdate();
|
|
1360
2905
|
await el.updateComplete;
|
|
1361
2906
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
"expected auto link summary",
|
|
1366
|
-
).textContent,
|
|
1367
|
-
).toContain("Auto link");
|
|
2907
|
+
await openPostMeta(el);
|
|
2908
|
+
|
|
2909
|
+
expect(pillValue(el, "slug")).toBe("Auto");
|
|
1368
2910
|
});
|
|
1369
2911
|
|
|
1370
2912
|
it("blocks publishing with a future publish date", async () => {
|
|
@@ -1389,11 +2931,12 @@ describe("JantComposeDialog", () => {
|
|
|
1389
2931
|
await editor.updateComplete;
|
|
1390
2932
|
|
|
1391
2933
|
requireElement(
|
|
1392
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2934
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1393
2935
|
"expected publish settings toggle",
|
|
1394
2936
|
).click();
|
|
1395
2937
|
await el.updateComplete;
|
|
1396
2938
|
|
|
2939
|
+
await openPostMeta(el);
|
|
1397
2940
|
const publishedAtInput = requireElement(
|
|
1398
2941
|
el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
|
|
1399
2942
|
"expected publish date input",
|
|
@@ -1420,13 +2963,13 @@ describe("JantComposeDialog", () => {
|
|
|
1420
2963
|
const el = await createElement();
|
|
1421
2964
|
|
|
1422
2965
|
requireElement(
|
|
1423
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2966
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1424
2967
|
"expected publish settings toggle",
|
|
1425
2968
|
).click();
|
|
1426
2969
|
await el.updateComplete;
|
|
1427
2970
|
|
|
1428
2971
|
const options = el.querySelectorAll<HTMLButtonElement>(
|
|
1429
|
-
".compose-
|
|
2972
|
+
".compose-sheet-row[role='radio']",
|
|
1430
2973
|
);
|
|
1431
2974
|
expect(options).toHaveLength(3);
|
|
1432
2975
|
options[2]?.click();
|
|
@@ -1444,24 +2987,153 @@ describe("JantComposeDialog", () => {
|
|
|
1444
2987
|
const el = await createElement();
|
|
1445
2988
|
|
|
1446
2989
|
requireElement(
|
|
1447
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
2990
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1448
2991
|
"expected publish settings toggle",
|
|
1449
2992
|
).click();
|
|
1450
2993
|
await el.updateComplete;
|
|
1451
2994
|
|
|
1452
2995
|
const options = el.querySelectorAll<HTMLButtonElement>(
|
|
1453
|
-
".compose-
|
|
2996
|
+
".compose-sheet-row[role='radio']",
|
|
1454
2997
|
);
|
|
1455
2998
|
expect(options).toHaveLength(3);
|
|
1456
2999
|
options[1]?.click();
|
|
1457
3000
|
await el.updateComplete;
|
|
1458
3001
|
|
|
1459
3002
|
expect(
|
|
1460
|
-
requireElement(
|
|
1461
|
-
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
1462
|
-
"expected publish button",
|
|
1463
|
-
).textContent?.trim(),
|
|
1464
|
-
).toBe("Post hidden");
|
|
3003
|
+
requireElement(
|
|
3004
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
3005
|
+
"expected publish button",
|
|
3006
|
+
).textContent?.trim(),
|
|
3007
|
+
).toBe("Post hidden");
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
it("flips visibility from the Hide from Latest shortcut", async () => {
|
|
3011
|
+
const el = await createElement();
|
|
3012
|
+
|
|
3013
|
+
const toggle = requireElement(
|
|
3014
|
+
el.querySelector<HTMLInputElement>(
|
|
3015
|
+
".compose-quick-actions-row .compose-publish-quick-toggle-input",
|
|
3016
|
+
),
|
|
3017
|
+
"expected hide-from-latest shortcut",
|
|
3018
|
+
);
|
|
3019
|
+
expect(toggle.checked).toBe(false);
|
|
3020
|
+
|
|
3021
|
+
toggle.checked = true;
|
|
3022
|
+
toggle.dispatchEvent(new globalThis.Event("change"));
|
|
3023
|
+
await el.updateComplete;
|
|
3024
|
+
expect(el._visibility).toBe("latest_hidden");
|
|
3025
|
+
|
|
3026
|
+
toggle.checked = false;
|
|
3027
|
+
toggle.dispatchEvent(new globalThis.Event("change"));
|
|
3028
|
+
await el.updateComplete;
|
|
3029
|
+
expect(el._visibility).toBe("public");
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
it("drops the Hide from Latest shortcut once the post is private", async () => {
|
|
3033
|
+
const el = await createElement();
|
|
3034
|
+
|
|
3035
|
+
requireElement(
|
|
3036
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
3037
|
+
"expected publish settings toggle",
|
|
3038
|
+
).click();
|
|
3039
|
+
await el.updateComplete;
|
|
3040
|
+
|
|
3041
|
+
// A checkbox cannot speak for a third state, so it steps aside.
|
|
3042
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
3043
|
+
".compose-sheet-row[role='radio']",
|
|
3044
|
+
)[2]?.click();
|
|
3045
|
+
await el.updateComplete;
|
|
3046
|
+
|
|
3047
|
+
expect(el._visibility).toBe("private");
|
|
3048
|
+
expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
it("offers the quiet reply shortcut on a reply, in place of visibility", async () => {
|
|
3052
|
+
const el = await createElement();
|
|
3053
|
+
|
|
3054
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
3055
|
+
contentHtml: "<p>Parent</p>",
|
|
3056
|
+
dateText: "Mar 14",
|
|
3057
|
+
});
|
|
3058
|
+
await flushUpdates(el);
|
|
3059
|
+
|
|
3060
|
+
const toggles = el.querySelectorAll<HTMLInputElement>(
|
|
3061
|
+
".compose-quick-actions-row .compose-publish-quick-toggle-input",
|
|
3062
|
+
);
|
|
3063
|
+
// A reply inherits the root's visibility, so only quiet reply is offered.
|
|
3064
|
+
expect(toggles).toHaveLength(1);
|
|
3065
|
+
|
|
3066
|
+
const toggle = requireElement(
|
|
3067
|
+
toggles.item(0),
|
|
3068
|
+
"expected quiet reply shortcut",
|
|
3069
|
+
);
|
|
3070
|
+
toggle.checked = true;
|
|
3071
|
+
toggle.dispatchEvent(new globalThis.Event("change"));
|
|
3072
|
+
await el.updateComplete;
|
|
3073
|
+
|
|
3074
|
+
expect(el._quietReply).toBe(true);
|
|
3075
|
+
expect(
|
|
3076
|
+
requireElement(
|
|
3077
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
3078
|
+
"expected publish button",
|
|
3079
|
+
).textContent?.trim(),
|
|
3080
|
+
).toBe("Reply quietly");
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
it("drops the quiet reply switch when editing an existing reply", async () => {
|
|
3084
|
+
const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
|
|
3085
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
|
3086
|
+
const url = String(input);
|
|
3087
|
+
const json = url.includes(parentId)
|
|
3088
|
+
? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
|
|
3089
|
+
: {
|
|
3090
|
+
id: "pst_123",
|
|
3091
|
+
format: "note",
|
|
3092
|
+
title: null,
|
|
3093
|
+
body: null,
|
|
3094
|
+
replyToId: parentId,
|
|
3095
|
+
};
|
|
3096
|
+
return Promise.resolve(
|
|
3097
|
+
new Response(JSON.stringify(json), {
|
|
3098
|
+
headers: { "Content-Type": "application/json" },
|
|
3099
|
+
}),
|
|
3100
|
+
);
|
|
3101
|
+
});
|
|
3102
|
+
|
|
3103
|
+
const el = await createElement();
|
|
3104
|
+
await el.openEdit("pst_123");
|
|
3105
|
+
await flushUpdates(el);
|
|
3106
|
+
|
|
3107
|
+
// Saving an edit goes through the update path, which has no create-time
|
|
3108
|
+
// thread bump to skip — so the switch is gone rather than dead.
|
|
3109
|
+
expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
|
|
3110
|
+
|
|
3111
|
+
el._showPublishPanel = true;
|
|
3112
|
+
await flushUpdates(el);
|
|
3113
|
+
expect(
|
|
3114
|
+
Array.from(el.querySelectorAll(".compose-sheet-title")).some(
|
|
3115
|
+
(row) => row.textContent?.trim() === labels.quietReplyLabel,
|
|
3116
|
+
),
|
|
3117
|
+
).toBe(false);
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
it("keeps the quiet reply switch on a thread draft, which saves by recreating", async () => {
|
|
3121
|
+
const el = await createElement();
|
|
3122
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
3123
|
+
contentHtml: "<p>Parent</p>",
|
|
3124
|
+
dateText: "Mar 14",
|
|
3125
|
+
});
|
|
3126
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
3127
|
+
// Re-editing a saved thread draft deletes and recreates every post, so the
|
|
3128
|
+
// create-time flag still lands.
|
|
3129
|
+
el._draftSourceId = "pst_draft";
|
|
3130
|
+
await flushUpdates(el);
|
|
3131
|
+
|
|
3132
|
+
expect(
|
|
3133
|
+
el.querySelectorAll(
|
|
3134
|
+
".compose-quick-actions-row .compose-publish-quick-toggle-input",
|
|
3135
|
+
),
|
|
3136
|
+
).toHaveLength(1);
|
|
1465
3137
|
});
|
|
1466
3138
|
|
|
1467
3139
|
it("opens a new post with the requested collection and keeps the last visibility until refresh", async () => {
|
|
@@ -1474,13 +3146,13 @@ describe("JantComposeDialog", () => {
|
|
|
1474
3146
|
expect(el._visibility).toBe("public");
|
|
1475
3147
|
|
|
1476
3148
|
requireElement(
|
|
1477
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3149
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1478
3150
|
"expected publish settings toggle",
|
|
1479
3151
|
).click();
|
|
1480
3152
|
await el.updateComplete;
|
|
1481
3153
|
|
|
1482
3154
|
const options = el.querySelectorAll<HTMLButtonElement>(
|
|
1483
|
-
".compose-
|
|
3155
|
+
".compose-sheet-row[role='radio']",
|
|
1484
3156
|
);
|
|
1485
3157
|
options[1]?.click();
|
|
1486
3158
|
await el.updateComplete;
|
|
@@ -1522,7 +3194,6 @@ describe("JantComposeDialog", () => {
|
|
|
1522
3194
|
slug: "",
|
|
1523
3195
|
visibility: "public",
|
|
1524
3196
|
rating: 0,
|
|
1525
|
-
showTitle: false,
|
|
1526
3197
|
showRating: false,
|
|
1527
3198
|
collectionIds: ["col-1"],
|
|
1528
3199
|
attachedTexts: [],
|
|
@@ -1537,6 +3208,250 @@ describe("JantComposeDialog", () => {
|
|
|
1537
3208
|
expect(el._collectionIds).toEqual(["col-2", "col-1"]);
|
|
1538
3209
|
});
|
|
1539
3210
|
|
|
3211
|
+
function seedDraftWithMedia() {
|
|
3212
|
+
globalThis.localStorage.setItem(
|
|
3213
|
+
"jant:compose-draft",
|
|
3214
|
+
JSON.stringify({
|
|
3215
|
+
format: "note",
|
|
3216
|
+
title: "",
|
|
3217
|
+
bodyJson: {
|
|
3218
|
+
type: "doc",
|
|
3219
|
+
content: [
|
|
3220
|
+
{
|
|
3221
|
+
type: "paragraph",
|
|
3222
|
+
content: [{ type: "text", text: "Draft with images" }],
|
|
3223
|
+
},
|
|
3224
|
+
],
|
|
3225
|
+
},
|
|
3226
|
+
url: "",
|
|
3227
|
+
quoteText: "",
|
|
3228
|
+
quoteAuthor: "",
|
|
3229
|
+
slug: "",
|
|
3230
|
+
visibility: "public",
|
|
3231
|
+
rating: 0,
|
|
3232
|
+
showTitle: false,
|
|
3233
|
+
showRating: false,
|
|
3234
|
+
collectionIds: [],
|
|
3235
|
+
replyToId: null,
|
|
3236
|
+
attachedTexts: [],
|
|
3237
|
+
attachmentOrder: ["client-a", "client-b"],
|
|
3238
|
+
mediaAttachments: [
|
|
3239
|
+
{
|
|
3240
|
+
clientId: "client-a",
|
|
3241
|
+
mediaId: "med_aaa",
|
|
3242
|
+
url: "/media/aaa.jpg",
|
|
3243
|
+
mimeType: "image/jpeg",
|
|
3244
|
+
name: "aaa.jpg",
|
|
3245
|
+
alt: "first",
|
|
3246
|
+
},
|
|
3247
|
+
],
|
|
3248
|
+
savedAt: Date.now(),
|
|
3249
|
+
}),
|
|
3250
|
+
);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
it("restores uploaded media from the draft snapshot plus bridge-supplied uploads", async () => {
|
|
3254
|
+
const el = await createElement();
|
|
3255
|
+
seedDraftWithMedia();
|
|
3256
|
+
|
|
3257
|
+
// client-b's upload finished after the dialog closed — only the bridge
|
|
3258
|
+
// knows about it, so it arrives via restoreMedia instead of the snapshot.
|
|
3259
|
+
await el.openNew({
|
|
3260
|
+
restoreDraft: true,
|
|
3261
|
+
restoreToast: false,
|
|
3262
|
+
restoreMedia: [
|
|
3263
|
+
{
|
|
3264
|
+
clientId: "client-b",
|
|
3265
|
+
mediaId: "med_bbb",
|
|
3266
|
+
url: "/media/bbb.png",
|
|
3267
|
+
mimeType: "image/png",
|
|
3268
|
+
name: "bbb.png",
|
|
3269
|
+
},
|
|
3270
|
+
],
|
|
3271
|
+
});
|
|
3272
|
+
await flushUpdates(el);
|
|
3273
|
+
|
|
3274
|
+
const editor = requireElement(
|
|
3275
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
3276
|
+
"expected compose editor",
|
|
3277
|
+
);
|
|
3278
|
+
const attachments = editor._attachments;
|
|
3279
|
+
expect(attachments.map((a) => a.clientId)).toEqual([
|
|
3280
|
+
"client-a",
|
|
3281
|
+
"client-b",
|
|
3282
|
+
]);
|
|
3283
|
+
expect(attachments.map((a) => a.mediaId)).toEqual(["med_aaa", "med_bbb"]);
|
|
3284
|
+
expect(attachments.every((a) => a.status === "done")).toBe(true);
|
|
3285
|
+
expect(attachments[0].remoteUrl).toBe("/media/aaa.jpg");
|
|
3286
|
+
expect(attachments[0].alt).toBe("first");
|
|
3287
|
+
expect(editor._attachmentOrder).toEqual(["client-a", "client-b"]);
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
it("suppresses the draft-restored toast when restoreToast is false", async () => {
|
|
3291
|
+
const container = document.createElement("div");
|
|
3292
|
+
container.id = "toast-container";
|
|
3293
|
+
document.body.appendChild(container);
|
|
3294
|
+
const el = await createElement();
|
|
3295
|
+
seedDraftWithMedia();
|
|
3296
|
+
|
|
3297
|
+
await el.openNew({ restoreDraft: true, restoreToast: false });
|
|
3298
|
+
await flushUpdates(el);
|
|
3299
|
+
expect(container.querySelector(".toast")).toBeNull();
|
|
3300
|
+
|
|
3301
|
+
el.reset();
|
|
3302
|
+
await el.openNew({ restoreDraft: true });
|
|
3303
|
+
await flushUpdates(el);
|
|
3304
|
+
expect(container.querySelector(".toast span")?.textContent).toBe(
|
|
3305
|
+
"Draft restored.",
|
|
3306
|
+
);
|
|
3307
|
+
});
|
|
3308
|
+
|
|
3309
|
+
const NOTE_TITLE_KEY = "jant:compose-note-title";
|
|
3310
|
+
|
|
3311
|
+
function titleToggle(el: JantComposeDialog, index = 0): HTMLButtonElement {
|
|
3312
|
+
return requireElement(
|
|
3313
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
3314
|
+
'.compose-tool-btn[title="Title"]',
|
|
3315
|
+
)[index] ?? null,
|
|
3316
|
+
"expected title toggle",
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
it("remembers a note's title toggle for the next new post", async () => {
|
|
3321
|
+
const el = await createElement();
|
|
3322
|
+
await el.openNew({ restoreDraft: false });
|
|
3323
|
+
await flushUpdates(el);
|
|
3324
|
+
|
|
3325
|
+
expect(el.querySelector(".compose-note-title")).not.toBeNull();
|
|
3326
|
+
|
|
3327
|
+
titleToggle(el).click();
|
|
3328
|
+
await flushUpdates(el);
|
|
3329
|
+
|
|
3330
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("0");
|
|
3331
|
+
expect(el.querySelector(".compose-note-title")).toBeNull();
|
|
3332
|
+
|
|
3333
|
+
await el.openNew({ restoreDraft: false });
|
|
3334
|
+
await flushUpdates(el);
|
|
3335
|
+
|
|
3336
|
+
expect(el.querySelector(".compose-note-title")).toBeNull();
|
|
3337
|
+
|
|
3338
|
+
titleToggle(el).click();
|
|
3339
|
+
await flushUpdates(el);
|
|
3340
|
+
|
|
3341
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("1");
|
|
3342
|
+
|
|
3343
|
+
await el.openNew({ restoreDraft: false });
|
|
3344
|
+
await flushUpdates(el);
|
|
3345
|
+
|
|
3346
|
+
expect(el.querySelector(".compose-note-title")).not.toBeNull();
|
|
3347
|
+
});
|
|
3348
|
+
|
|
3349
|
+
it("opens a fresh composer without the title field when it was last turned off", async () => {
|
|
3350
|
+
globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
|
|
3351
|
+
|
|
3352
|
+
const el = await createElement();
|
|
3353
|
+
|
|
3354
|
+
expect(el.querySelector(".compose-note-title")).toBeNull();
|
|
3355
|
+
});
|
|
3356
|
+
|
|
3357
|
+
it("does not remember the title toggle from an edit", async () => {
|
|
3358
|
+
mockEditPost({ format: "note", title: "Hello", body: null });
|
|
3359
|
+
|
|
3360
|
+
const el = await createElement();
|
|
3361
|
+
await el.openEdit("pst_123");
|
|
3362
|
+
await flushUpdates(el);
|
|
3363
|
+
|
|
3364
|
+
titleToggle(el).click();
|
|
3365
|
+
await flushUpdates(el);
|
|
3366
|
+
|
|
3367
|
+
// Dropping this post's title says nothing about the next post's.
|
|
3368
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
it("does not remember the title toggle from a reply", async () => {
|
|
3372
|
+
const el = await createElement();
|
|
3373
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
3374
|
+
contentHtml: "<p>Parent</p>",
|
|
3375
|
+
dateText: "Mar 14",
|
|
3376
|
+
});
|
|
3377
|
+
await flushUpdates(el);
|
|
3378
|
+
|
|
3379
|
+
titleToggle(el).click();
|
|
3380
|
+
await flushUpdates(el);
|
|
3381
|
+
|
|
3382
|
+
expect(el.querySelector(".compose-note-title")).not.toBeNull();
|
|
3383
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
|
|
3384
|
+
});
|
|
3385
|
+
|
|
3386
|
+
it("does not remember the title toggle from a thread post", async () => {
|
|
3387
|
+
const el = await createElement();
|
|
3388
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
3389
|
+
await flushUpdates(el);
|
|
3390
|
+
|
|
3391
|
+
titleToggle(el).click();
|
|
3392
|
+
await flushUpdates(el);
|
|
3393
|
+
|
|
3394
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
|
|
3395
|
+
});
|
|
3396
|
+
|
|
3397
|
+
it("does not remember a title the editor reveals on its own", async () => {
|
|
3398
|
+
const el = await createElement();
|
|
3399
|
+
await el.openNew({ restoreDraft: false });
|
|
3400
|
+
await flushUpdates(el);
|
|
3401
|
+
|
|
3402
|
+
const editor = requireElement(
|
|
3403
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
3404
|
+
"expected compose editor",
|
|
3405
|
+
);
|
|
3406
|
+
// The fullscreen and H1-promotion paths land here. Neither is the author
|
|
3407
|
+
// stating a habit, so neither writes one down.
|
|
3408
|
+
editor.setEditorState(null, "Promoted", true);
|
|
3409
|
+
await flushUpdates(el);
|
|
3410
|
+
|
|
3411
|
+
expect(el.querySelector(".compose-note-title")).not.toBeNull();
|
|
3412
|
+
expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
|
|
3413
|
+
});
|
|
3414
|
+
|
|
3415
|
+
it("lets a restored draft's title state win over the remembered default", async () => {
|
|
3416
|
+
globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
|
|
3417
|
+
globalThis.localStorage.setItem(
|
|
3418
|
+
"jant:compose-draft",
|
|
3419
|
+
JSON.stringify({
|
|
3420
|
+
format: "note",
|
|
3421
|
+
title: "Draft title",
|
|
3422
|
+
showTitle: true,
|
|
3423
|
+
bodyJson: {
|
|
3424
|
+
type: "doc",
|
|
3425
|
+
content: [
|
|
3426
|
+
{
|
|
3427
|
+
type: "paragraph",
|
|
3428
|
+
content: [{ type: "text", text: "Draft body" }],
|
|
3429
|
+
},
|
|
3430
|
+
],
|
|
3431
|
+
},
|
|
3432
|
+
url: "",
|
|
3433
|
+
quoteText: "",
|
|
3434
|
+
quoteAuthor: "",
|
|
3435
|
+
slug: "",
|
|
3436
|
+
visibility: "public",
|
|
3437
|
+
rating: 0,
|
|
3438
|
+
showRating: false,
|
|
3439
|
+
collectionIds: [],
|
|
3440
|
+
attachedTexts: [],
|
|
3441
|
+
attachmentOrder: [],
|
|
3442
|
+
savedAt: Date.now(),
|
|
3443
|
+
}),
|
|
3444
|
+
);
|
|
3445
|
+
|
|
3446
|
+
const el = await createElement();
|
|
3447
|
+
await el.openNew();
|
|
3448
|
+
await flushUpdates(el);
|
|
3449
|
+
|
|
3450
|
+
expect(
|
|
3451
|
+
el.querySelector<HTMLInputElement>(".compose-note-title")?.value,
|
|
3452
|
+
).toBe("Draft title");
|
|
3453
|
+
});
|
|
3454
|
+
|
|
1540
3455
|
it("includes a custom slug from the publish settings panel in the submit payload", async () => {
|
|
1541
3456
|
mockSlugApi((url) => {
|
|
1542
3457
|
if (url.searchParams.get("mode") === "suggest") {
|
|
@@ -1565,13 +3480,14 @@ describe("JantComposeDialog", () => {
|
|
|
1565
3480
|
await editor.updateComplete;
|
|
1566
3481
|
|
|
1567
3482
|
const publishToggle = requireElement(
|
|
1568
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3483
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1569
3484
|
"expected publish settings toggle",
|
|
1570
3485
|
);
|
|
1571
3486
|
publishToggle.click();
|
|
1572
3487
|
await el.updateComplete;
|
|
1573
3488
|
await flushUpdates(el);
|
|
1574
3489
|
|
|
3490
|
+
await openPostMeta(el);
|
|
1575
3491
|
const slugInput = requireElement(
|
|
1576
3492
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1577
3493
|
"expected custom link input",
|
|
@@ -1611,7 +3527,7 @@ describe("JantComposeDialog", () => {
|
|
|
1611
3527
|
const el = await createElement();
|
|
1612
3528
|
|
|
1613
3529
|
const publishToggle = requireElement(
|
|
1614
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3530
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1615
3531
|
"expected publish settings toggle",
|
|
1616
3532
|
);
|
|
1617
3533
|
|
|
@@ -1619,6 +3535,7 @@ describe("JantComposeDialog", () => {
|
|
|
1619
3535
|
await el.updateComplete;
|
|
1620
3536
|
await flushUpdates(el);
|
|
1621
3537
|
|
|
3538
|
+
await openPostMeta(el);
|
|
1622
3539
|
const slugInput = requireElement(
|
|
1623
3540
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1624
3541
|
"expected custom link input",
|
|
@@ -1633,6 +3550,7 @@ describe("JantComposeDialog", () => {
|
|
|
1633
3550
|
publishToggle.click();
|
|
1634
3551
|
await el.updateComplete;
|
|
1635
3552
|
|
|
3553
|
+
await openPostMeta(el);
|
|
1636
3554
|
expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
|
|
1637
3555
|
});
|
|
1638
3556
|
|
|
@@ -1691,7 +3609,7 @@ describe("JantComposeDialog", () => {
|
|
|
1691
3609
|
} as never);
|
|
1692
3610
|
|
|
1693
3611
|
requireElement(
|
|
1694
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3612
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1695
3613
|
"expected publish settings toggle",
|
|
1696
3614
|
).click();
|
|
1697
3615
|
await el.updateComplete;
|
|
@@ -1762,7 +3680,7 @@ describe("JantComposeDialog", () => {
|
|
|
1762
3680
|
} as never);
|
|
1763
3681
|
|
|
1764
3682
|
requireElement(
|
|
1765
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3683
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1766
3684
|
"expected publish settings toggle",
|
|
1767
3685
|
).click();
|
|
1768
3686
|
await el.updateComplete;
|
|
@@ -1784,7 +3702,7 @@ describe("JantComposeDialog", () => {
|
|
|
1784
3702
|
const el = await createElement();
|
|
1785
3703
|
|
|
1786
3704
|
requireElement(
|
|
1787
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3705
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1788
3706
|
"expected publish settings toggle",
|
|
1789
3707
|
).click();
|
|
1790
3708
|
await el.updateComplete;
|
|
@@ -1812,13 +3730,14 @@ describe("JantComposeDialog", () => {
|
|
|
1812
3730
|
await editor.updateComplete;
|
|
1813
3731
|
|
|
1814
3732
|
const publishToggle = requireElement(
|
|
1815
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3733
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1816
3734
|
"expected publish settings toggle",
|
|
1817
3735
|
);
|
|
1818
3736
|
publishToggle.click();
|
|
1819
3737
|
await el.updateComplete;
|
|
1820
3738
|
await flushUpdates(el);
|
|
1821
3739
|
|
|
3740
|
+
await openPostMeta(el);
|
|
1822
3741
|
const slugInput = requireElement(
|
|
1823
3742
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1824
3743
|
"expected custom link input",
|
|
@@ -1863,7 +3782,6 @@ describe("JantComposeDialog", () => {
|
|
|
1863
3782
|
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1864
3783
|
"expected compose editor",
|
|
1865
3784
|
);
|
|
1866
|
-
editor._showTitle = true;
|
|
1867
3785
|
editor._title = "Hello World";
|
|
1868
3786
|
editor._bodyJson = {
|
|
1869
3787
|
type: "doc",
|
|
@@ -1874,13 +3792,14 @@ describe("JantComposeDialog", () => {
|
|
|
1874
3792
|
await editor.updateComplete;
|
|
1875
3793
|
|
|
1876
3794
|
const publishToggle = requireElement(
|
|
1877
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3795
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1878
3796
|
"expected publish settings toggle",
|
|
1879
3797
|
);
|
|
1880
3798
|
publishToggle.click();
|
|
1881
3799
|
await el.updateComplete;
|
|
1882
3800
|
await flushUpdates(el);
|
|
1883
3801
|
|
|
3802
|
+
await openPostMeta(el);
|
|
1884
3803
|
expect(
|
|
1885
3804
|
el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
|
|
1886
3805
|
).toBe("/hello-world");
|
|
@@ -1901,11 +3820,11 @@ describe("JantComposeDialog", () => {
|
|
|
1901
3820
|
).toBeUndefined();
|
|
1902
3821
|
});
|
|
1903
3822
|
|
|
1904
|
-
it("
|
|
3823
|
+
it("keeps the publish panel to whole-submission settings", async () => {
|
|
1905
3824
|
const el = await createElement();
|
|
1906
3825
|
|
|
1907
3826
|
const publishToggle = requireElement(
|
|
1908
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3827
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1909
3828
|
"expected publish settings toggle",
|
|
1910
3829
|
);
|
|
1911
3830
|
publishToggle.click();
|
|
@@ -1917,12 +3836,16 @@ describe("JantComposeDialog", () => {
|
|
|
1917
3836
|
);
|
|
1918
3837
|
expect(panel.textContent).not.toContain("Publish settings");
|
|
1919
3838
|
expect(panel.textContent).toContain("Visibility");
|
|
1920
|
-
expect(panel.textContent).toContain("Published on");
|
|
1921
|
-
expect(panel.textContent).toContain("Custom link");
|
|
1922
3839
|
expect(panel.textContent).not.toContain("Save as draft");
|
|
1923
3840
|
expect(panel.textContent).not.toContain("Discard");
|
|
1924
|
-
|
|
1925
|
-
expect(panel.
|
|
3841
|
+
// Post-scoped settings are not in here — they live on the post.
|
|
3842
|
+
expect(panel.textContent).not.toContain("Published on");
|
|
3843
|
+
expect(panel.textContent).not.toContain("Custom link");
|
|
3844
|
+
|
|
3845
|
+
await openPostMeta(el);
|
|
3846
|
+
expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
|
|
3847
|
+
await openPostMeta(el);
|
|
3848
|
+
expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
|
|
1926
3849
|
});
|
|
1927
3850
|
|
|
1928
3851
|
it("clears the custom slug with the reset action", async () => {
|
|
@@ -1938,13 +3861,14 @@ describe("JantComposeDialog", () => {
|
|
|
1938
3861
|
|
|
1939
3862
|
const el = await createElement();
|
|
1940
3863
|
const publishToggle = requireElement(
|
|
1941
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3864
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1942
3865
|
"expected publish settings toggle",
|
|
1943
3866
|
);
|
|
1944
3867
|
publishToggle.click();
|
|
1945
3868
|
await el.updateComplete;
|
|
1946
3869
|
await flushUpdates(el);
|
|
1947
3870
|
|
|
3871
|
+
await openPostMeta(el);
|
|
1948
3872
|
const slugInput = requireElement(
|
|
1949
3873
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
1950
3874
|
"expected custom link input",
|
|
@@ -1964,6 +3888,7 @@ describe("JantComposeDialog", () => {
|
|
|
1964
3888
|
).click();
|
|
1965
3889
|
await flushUpdates(el);
|
|
1966
3890
|
|
|
3891
|
+
await openPostMeta(el);
|
|
1967
3892
|
expect(
|
|
1968
3893
|
requireElement(
|
|
1969
3894
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
@@ -1986,7 +3911,7 @@ describe("JantComposeDialog", () => {
|
|
|
1986
3911
|
await el.updateComplete;
|
|
1987
3912
|
|
|
1988
3913
|
const publishToggle = requireElement(
|
|
1989
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3914
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
1990
3915
|
"expected publish settings toggle",
|
|
1991
3916
|
);
|
|
1992
3917
|
publishToggle.click();
|
|
@@ -2018,18 +3943,18 @@ describe("JantComposeDialog", () => {
|
|
|
2018
3943
|
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
2019
3944
|
"expected compose editor",
|
|
2020
3945
|
);
|
|
2021
|
-
editor._showTitle = true;
|
|
2022
3946
|
editor._title = "Hello World";
|
|
2023
3947
|
await editor.updateComplete;
|
|
2024
3948
|
|
|
2025
3949
|
const publishToggle = requireElement(
|
|
2026
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
3950
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
2027
3951
|
"expected publish settings toggle",
|
|
2028
3952
|
);
|
|
2029
3953
|
publishToggle.click();
|
|
2030
3954
|
await el.updateComplete;
|
|
2031
3955
|
await flushUpdates(el);
|
|
2032
3956
|
|
|
3957
|
+
await openPostMeta(el);
|
|
2033
3958
|
expect(
|
|
2034
3959
|
el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
|
|
2035
3960
|
).toBe("/hello-world");
|
|
@@ -2079,13 +4004,14 @@ describe("JantComposeDialog", () => {
|
|
|
2079
4004
|
await editor.updateComplete;
|
|
2080
4005
|
|
|
2081
4006
|
const publishToggle = requireElement(
|
|
2082
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
4007
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
2083
4008
|
"expected publish settings toggle",
|
|
2084
4009
|
);
|
|
2085
4010
|
publishToggle.click();
|
|
2086
4011
|
await el.updateComplete;
|
|
2087
4012
|
await flushUpdates(el);
|
|
2088
4013
|
|
|
4014
|
+
await openPostMeta(el);
|
|
2089
4015
|
const slugInput = requireElement(
|
|
2090
4016
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
2091
4017
|
"expected custom link input",
|
|
@@ -2136,11 +4062,12 @@ describe("JantComposeDialog", () => {
|
|
|
2136
4062
|
await editor.updateComplete;
|
|
2137
4063
|
|
|
2138
4064
|
requireElement(
|
|
2139
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
4065
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
2140
4066
|
"expected publish settings toggle",
|
|
2141
4067
|
).click();
|
|
2142
4068
|
await el.updateComplete;
|
|
2143
4069
|
|
|
4070
|
+
await openPostMeta(el);
|
|
2144
4071
|
const slugInput = requireElement(
|
|
2145
4072
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
2146
4073
|
"expected custom link input",
|
|
@@ -2188,11 +4115,12 @@ describe("JantComposeDialog", () => {
|
|
|
2188
4115
|
await editor.updateComplete;
|
|
2189
4116
|
|
|
2190
4117
|
requireElement(
|
|
2191
|
-
el.querySelector<HTMLButtonElement>(".compose-
|
|
4118
|
+
el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
|
|
2192
4119
|
"expected publish settings toggle",
|
|
2193
4120
|
).click();
|
|
2194
4121
|
await el.updateComplete;
|
|
2195
4122
|
|
|
4123
|
+
await openPostMeta(el);
|
|
2196
4124
|
const slugInput = requireElement(
|
|
2197
4125
|
el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
|
|
2198
4126
|
"expected custom link input",
|
|
@@ -2235,8 +4163,7 @@ describe("JantComposeDialog", () => {
|
|
|
2235
4163
|
await el.updateComplete;
|
|
2236
4164
|
|
|
2237
4165
|
let receivedDetail:
|
|
2238
|
-
|
|
2239
|
-
| null = null;
|
|
4166
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
2240
4167
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
2241
4168
|
const customEvent = event as CustomEvent<
|
|
2242
4169
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -2306,7 +4233,6 @@ describe("JantComposeDialog", () => {
|
|
|
2306
4233
|
slug: "",
|
|
2307
4234
|
visibility: "public",
|
|
2308
4235
|
rating: 0,
|
|
2309
|
-
showTitle: false,
|
|
2310
4236
|
showRating: false,
|
|
2311
4237
|
collectionIds: ["col-stale"],
|
|
2312
4238
|
replyToId,
|
|
@@ -2427,48 +4353,278 @@ describe("JantComposeDialog", () => {
|
|
|
2427
4353
|
expect(detail.threadPosts?.[0]?.quietReply).toBe(true);
|
|
2428
4354
|
});
|
|
2429
4355
|
|
|
2430
|
-
it("does not add more thread items than the shared limit", async () => {
|
|
2431
|
-
const el = await createElement();
|
|
2432
|
-
el._threadItems = Array.from({ length: MAX_THREAD_POSTS }, (_, index) => ({
|
|
2433
|
-
id: `thread-${index}`,
|
|
2434
|
-
format: "note",
|
|
2435
|
-
}));
|
|
4356
|
+
it("does not add more thread items than the shared limit", async () => {
|
|
4357
|
+
const el = await createElement();
|
|
4358
|
+
el._threadItems = Array.from({ length: MAX_THREAD_POSTS }, (_, index) => ({
|
|
4359
|
+
id: `thread-${index}`,
|
|
4360
|
+
format: "note",
|
|
4361
|
+
}));
|
|
4362
|
+
|
|
4363
|
+
(
|
|
4364
|
+
el as unknown as {
|
|
4365
|
+
_addThreadItem: () => void;
|
|
4366
|
+
}
|
|
4367
|
+
)._addThreadItem();
|
|
4368
|
+
|
|
4369
|
+
expect(el._threadItems).toHaveLength(MAX_THREAD_POSTS);
|
|
4370
|
+
});
|
|
4371
|
+
|
|
4372
|
+
it("scrolls the thread composer to the bottom after adding an item", async () => {
|
|
4373
|
+
const el = await createElement();
|
|
4374
|
+
el._threadItems = [
|
|
4375
|
+
{ id: "thread-1", format: "note" },
|
|
4376
|
+
{ id: "thread-2", format: "note" },
|
|
4377
|
+
];
|
|
4378
|
+
await el.updateComplete;
|
|
4379
|
+
|
|
4380
|
+
const scroller = requireElement(
|
|
4381
|
+
el.querySelector<HTMLElement>(".compose-scroll"),
|
|
4382
|
+
"expected compose scroll region",
|
|
4383
|
+
);
|
|
4384
|
+
Object.defineProperty(scroller, "scrollHeight", {
|
|
4385
|
+
configurable: true,
|
|
4386
|
+
value: 640,
|
|
4387
|
+
});
|
|
4388
|
+
scroller.scrollTop = 120;
|
|
4389
|
+
|
|
4390
|
+
(
|
|
4391
|
+
el as unknown as {
|
|
4392
|
+
_addThreadItem: () => void;
|
|
4393
|
+
}
|
|
4394
|
+
)._addThreadItem();
|
|
4395
|
+
await flushUpdates(el);
|
|
4396
|
+
|
|
4397
|
+
expect(scroller.scrollTop).toBe(640);
|
|
4398
|
+
});
|
|
4399
|
+
|
|
4400
|
+
describe("thread mode keeps what each post holds", () => {
|
|
4401
|
+
type ThreadInternals = {
|
|
4402
|
+
_addThreadItem: () => void;
|
|
4403
|
+
_removeThreadItem: (index: number) => void;
|
|
4404
|
+
_checkSlugAvailability: (slug: string, index?: number) => Promise<void>;
|
|
4405
|
+
_slug: string;
|
|
4406
|
+
_publishedAtInput: string;
|
|
4407
|
+
_publishedAtTimeMinutes: number | null;
|
|
4408
|
+
};
|
|
4409
|
+
|
|
4410
|
+
const internals = (el: JantComposeDialog) =>
|
|
4411
|
+
el as unknown as ThreadInternals;
|
|
4412
|
+
|
|
4413
|
+
const editorsOf = (el: JantComposeDialog) =>
|
|
4414
|
+
Array.from(el.querySelectorAll<JantComposeEditor>("jant-compose-editor"));
|
|
4415
|
+
|
|
4416
|
+
const paragraph = (text: string) => ({
|
|
4417
|
+
type: "doc",
|
|
4418
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
4419
|
+
});
|
|
4420
|
+
|
|
4421
|
+
function submit(el: JantComposeDialog): ComposeSubmitDetail {
|
|
4422
|
+
let detail: ComposeSubmitDetail | null = null;
|
|
4423
|
+
el.addEventListener(
|
|
4424
|
+
"jant:compose-submit-deferred",
|
|
4425
|
+
(event) => {
|
|
4426
|
+
detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
4427
|
+
},
|
|
4428
|
+
{ once: true },
|
|
4429
|
+
);
|
|
4430
|
+
requireElement(
|
|
4431
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
4432
|
+
"expected publish button",
|
|
4433
|
+
).click();
|
|
4434
|
+
if (!detail) throw new Error("expected a submit");
|
|
4435
|
+
return detail;
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
it("keeps a quote's source link on the way into a thread and back out", async () => {
|
|
4439
|
+
const el = await createElement();
|
|
4440
|
+
el._format = "quote";
|
|
4441
|
+
await flushUpdates(el);
|
|
4442
|
+
const single = editorsOf(el)[0]!;
|
|
4443
|
+
single._quoteText = "A borrowed sentence.";
|
|
4444
|
+
single._quoteAuthor = "Someone Else";
|
|
4445
|
+
single._url = "https://example.com/source";
|
|
4446
|
+
await flushUpdates(el);
|
|
4447
|
+
|
|
4448
|
+
internals(el)._addThreadItem();
|
|
4449
|
+
await flushUpdates(el);
|
|
4450
|
+
const root = editorsOf(el)[0]!;
|
|
4451
|
+
expect(root.getData()).toMatchObject({
|
|
4452
|
+
url: "https://example.com/source",
|
|
4453
|
+
quoteText: "A borrowed sentence.",
|
|
4454
|
+
quoteAuthor: "Someone Else",
|
|
4455
|
+
});
|
|
4456
|
+
|
|
4457
|
+
internals(el)._removeThreadItem(1);
|
|
4458
|
+
await flushUpdates(el);
|
|
4459
|
+
expect(el._threadItems).toHaveLength(0);
|
|
4460
|
+
expect(editorsOf(el)[0]!.getData().url).toBe(
|
|
4461
|
+
"https://example.com/source",
|
|
4462
|
+
);
|
|
4463
|
+
expect(submit(el).url).toBe("https://example.com/source");
|
|
4464
|
+
});
|
|
4465
|
+
|
|
4466
|
+
it("publishes an attached text that has no media beside it after entering a thread", async () => {
|
|
4467
|
+
// The strip and the submit both walk the attachment order, which was
|
|
4468
|
+
// only carried across when the post also had media.
|
|
4469
|
+
const el = await createElement();
|
|
4470
|
+
const single = editorsOf(el)[0]!;
|
|
4471
|
+
single._bodyJson = paragraph("See the notes below.");
|
|
4472
|
+
single._attachedTexts = [
|
|
4473
|
+
{
|
|
4474
|
+
clientId: "text-1",
|
|
4475
|
+
bodyJson: paragraph("The notes."),
|
|
4476
|
+
bodyHtml: "",
|
|
4477
|
+
summary: "The notes.",
|
|
4478
|
+
},
|
|
4479
|
+
];
|
|
4480
|
+
single._attachmentOrder = ["text-1"];
|
|
4481
|
+
await flushUpdates(el);
|
|
4482
|
+
|
|
4483
|
+
internals(el)._addThreadItem();
|
|
4484
|
+
await flushUpdates(el);
|
|
4485
|
+
const [root, next] = editorsOf(el);
|
|
4486
|
+
expect(root!.querySelectorAll("[data-attachment-list] > *")).toHaveLength(
|
|
4487
|
+
1,
|
|
4488
|
+
);
|
|
4489
|
+
next!._bodyJson = paragraph("More.");
|
|
4490
|
+
await flushUpdates(el);
|
|
4491
|
+
|
|
4492
|
+
const detail = submit(el);
|
|
4493
|
+
expect(detail.threadPosts?.[0]?.attachments).toEqual([
|
|
4494
|
+
expect.objectContaining({ type: "text", clientId: "text-1" }),
|
|
4495
|
+
]);
|
|
4496
|
+
});
|
|
4497
|
+
|
|
4498
|
+
it("moves the next post's own permalink and date up when the first post is removed", async () => {
|
|
4499
|
+
// The root's permalink and date live on the dialog, every other post's
|
|
4500
|
+
// on its thread item. Removing the root used to leave its values on the
|
|
4501
|
+
// post that took its place, and drop that post's own.
|
|
4502
|
+
const el = await createElement();
|
|
4503
|
+
el._threadItems = [
|
|
4504
|
+
{ id: "a", format: "note" },
|
|
4505
|
+
{
|
|
4506
|
+
id: "b",
|
|
4507
|
+
format: "note",
|
|
4508
|
+
slug: "part-two",
|
|
4509
|
+
publishedAtInput: "2025-01-02",
|
|
4510
|
+
publishedAtTimeMinutes: 600,
|
|
4511
|
+
},
|
|
4512
|
+
{ id: "c", format: "note" },
|
|
4513
|
+
];
|
|
4514
|
+
internals(el)._slug = "part-one";
|
|
4515
|
+
internals(el)._publishedAtInput = "2025-01-01";
|
|
4516
|
+
internals(el)._publishedAtTimeMinutes = 480;
|
|
4517
|
+
await flushUpdates(el);
|
|
4518
|
+
editorsOf(el).forEach((editor, i) => {
|
|
4519
|
+
editor._bodyJson = paragraph(`Post ${i + 1}`);
|
|
4520
|
+
});
|
|
4521
|
+
await flushUpdates(el);
|
|
4522
|
+
|
|
4523
|
+
internals(el)._removeThreadItem(0);
|
|
4524
|
+
await flushUpdates(el);
|
|
2436
4525
|
|
|
2437
|
-
|
|
2438
|
-
el
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
4526
|
+
expect(internals(el)._slug).toBe("part-two");
|
|
4527
|
+
expect(internals(el)._publishedAtInput).toBe("2025-01-02");
|
|
4528
|
+
expect(internals(el)._publishedAtTimeMinutes).toBe(600);
|
|
4529
|
+
const detail = submit(el);
|
|
4530
|
+
expect(detail.threadPosts?.map((post) => post.slug)).toEqual([
|
|
4531
|
+
"part-two",
|
|
4532
|
+
undefined,
|
|
4533
|
+
]);
|
|
4534
|
+
});
|
|
2442
4535
|
|
|
2443
|
-
|
|
2444
|
-
|
|
4536
|
+
function mockThreadDraft(requests: string[]) {
|
|
4537
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
4538
|
+
const url = String(input);
|
|
4539
|
+
requests.push(url);
|
|
4540
|
+
const json = (body: unknown) =>
|
|
4541
|
+
new Response(JSON.stringify(body), {
|
|
4542
|
+
headers: { "Content-Type": "application/json" },
|
|
4543
|
+
});
|
|
4544
|
+
if (url === "/api/posts/pst_draft_root") {
|
|
4545
|
+
return json({
|
|
4546
|
+
id: "pst_draft_root",
|
|
4547
|
+
threadId: "pst_draft_root",
|
|
4548
|
+
format: "note",
|
|
4549
|
+
status: "draft",
|
|
4550
|
+
slug: "draft-thread",
|
|
4551
|
+
title: "Draft thread",
|
|
4552
|
+
body: null,
|
|
4553
|
+
attachments: [],
|
|
4554
|
+
collectionIds: [],
|
|
4555
|
+
});
|
|
4556
|
+
}
|
|
4557
|
+
if (url === "/api/posts?status=draft&limit=50") {
|
|
4558
|
+
return json({
|
|
4559
|
+
posts: [
|
|
4560
|
+
{
|
|
4561
|
+
id: "pst_draft_reply",
|
|
4562
|
+
threadId: "pst_draft_root",
|
|
4563
|
+
replyToId: "pst_draft_root",
|
|
4564
|
+
format: "quote",
|
|
4565
|
+
status: "draft",
|
|
4566
|
+
slug: "part-two",
|
|
4567
|
+
quoteText: "Draft reply",
|
|
4568
|
+
sourceName: "Someone Else",
|
|
4569
|
+
attachments: [],
|
|
4570
|
+
},
|
|
4571
|
+
],
|
|
4572
|
+
});
|
|
4573
|
+
}
|
|
4574
|
+
if (url.startsWith("/api/posts/slug?")) {
|
|
4575
|
+
return json({ available: true });
|
|
4576
|
+
}
|
|
4577
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
4578
|
+
});
|
|
4579
|
+
}
|
|
2445
4580
|
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
4581
|
+
it("keeps each reply's permalink when a saved thread draft is reopened", async () => {
|
|
4582
|
+
// Saving a thread draft recreates it, so a slug not loaded back into
|
|
4583
|
+
// the composer came back as a random id on the next save.
|
|
4584
|
+
const requests: string[] = [];
|
|
4585
|
+
mockThreadDraft(requests);
|
|
4586
|
+
const el = await createElement();
|
|
4587
|
+
await el.openDraft("pst_draft_root");
|
|
4588
|
+
await flushUpdates(el);
|
|
2453
4589
|
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
4590
|
+
expect(el._threadItems[1]?.slug).toBe("part-two");
|
|
4591
|
+
|
|
4592
|
+
// A reply's slug already belongs to the saved reply, not to the root.
|
|
4593
|
+
await internals(el)._checkSlugAvailability("part-two", 1);
|
|
4594
|
+
await internals(el)._checkSlugAvailability("draft-thread", 0);
|
|
4595
|
+
const checks = requests
|
|
4596
|
+
.filter((url) => url.startsWith("/api/posts/slug?"))
|
|
4597
|
+
.map((url) => new URL(url, "http://x").searchParams.get("postId"));
|
|
4598
|
+
expect(checks).toEqual(["pst_draft_reply", "pst_draft_root"]);
|
|
4599
|
+
|
|
4600
|
+
expect(submit(el).threadPosts?.map((post) => post.slug)).toEqual([
|
|
4601
|
+
"draft-thread",
|
|
4602
|
+
"part-two",
|
|
4603
|
+
]);
|
|
2461
4604
|
});
|
|
2462
|
-
threadLayout.scrollTop = 120;
|
|
2463
4605
|
|
|
2464
|
-
(
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
4606
|
+
it("folds a thread post's quote into its body when its format changes on a saved draft", async () => {
|
|
4607
|
+
// Single-post drafts already did this; a thread post kept the quote in
|
|
4608
|
+
// a hidden field, which the next save then dropped.
|
|
4609
|
+
mockThreadDraft([]);
|
|
4610
|
+
const el = await createElement();
|
|
4611
|
+
await el.openDraft("pst_draft_root");
|
|
4612
|
+
await flushUpdates(el);
|
|
4613
|
+
|
|
4614
|
+
const reply = editorsOf(el)[1]!;
|
|
4615
|
+
const noteButton = requireElement(
|
|
4616
|
+
Array.from(
|
|
4617
|
+
reply.querySelectorAll<HTMLButtonElement>(".compose-segmented-item"),
|
|
4618
|
+
).find((button) => button.textContent?.trim() === labels.note) ?? null,
|
|
4619
|
+
"expected the reply's Note button",
|
|
4620
|
+
);
|
|
4621
|
+
noteButton.click();
|
|
4622
|
+
await flushUpdates(el);
|
|
2470
4623
|
|
|
2471
|
-
|
|
4624
|
+
expect(el._threadItems[1]?.format).toBe("note");
|
|
4625
|
+
expect(reply.getData().body).toContain("Draft reply");
|
|
4626
|
+
expect(submit(el).threadPosts?.[1]?.body).toContain("Draft reply");
|
|
4627
|
+
});
|
|
2472
4628
|
});
|
|
2473
4629
|
|
|
2474
4630
|
it("omits visibility from locked edit submissions", async () => {
|
|
@@ -2512,6 +4668,20 @@ describe("JantComposeDialog", () => {
|
|
|
2512
4668
|
);
|
|
2513
4669
|
});
|
|
2514
4670
|
|
|
4671
|
+
it("gives the collection trigger one glyph and no chevron", async () => {
|
|
4672
|
+
const el = await createElement();
|
|
4673
|
+
const trigger = requireElement(
|
|
4674
|
+
el.querySelector<HTMLButtonElement>(".compose-collection-trigger"),
|
|
4675
|
+
"expected collection trigger",
|
|
4676
|
+
);
|
|
4677
|
+
|
|
4678
|
+
expect(trigger.querySelectorAll("svg").length).toBe(1);
|
|
4679
|
+
expect(
|
|
4680
|
+
trigger.querySelector(".compose-collection-trigger-svg"),
|
|
4681
|
+
).not.toBeNull();
|
|
4682
|
+
expect(trigger.querySelector(".compose-collection-chevron")).toBeNull();
|
|
4683
|
+
});
|
|
4684
|
+
|
|
2515
4685
|
it("collection selector toggles IDs", async () => {
|
|
2516
4686
|
const el = await createElement();
|
|
2517
4687
|
|
|
@@ -3042,13 +5212,10 @@ describe("JantComposeDialog", () => {
|
|
|
3042
5212
|
};
|
|
3043
5213
|
await editor.updateComplete;
|
|
3044
5214
|
|
|
3045
|
-
// Click the
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
);
|
|
3050
|
-
draftBtn.click();
|
|
3051
|
-
await el.updateComplete;
|
|
5215
|
+
// Click the drafts row in the options panel — should show confirm panel
|
|
5216
|
+
await openPublishPanel(el);
|
|
5217
|
+
requireElement(draftsRow(el), "expected drafts row").click();
|
|
5218
|
+
await flushUpdates(el);
|
|
3052
5219
|
|
|
3053
5220
|
expect(el._confirmPanelOpen).toBe(true);
|
|
3054
5221
|
expect(el.querySelector(".compose-confirm-panel")).not.toBeNull();
|
|
@@ -3065,13 +5232,10 @@ describe("JantComposeDialog", () => {
|
|
|
3065
5232
|
}),
|
|
3066
5233
|
);
|
|
3067
5234
|
|
|
3068
|
-
// Click the
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
);
|
|
3073
|
-
draftBtn.click();
|
|
3074
|
-
await el.updateComplete;
|
|
5235
|
+
// Click the drafts row in the options panel — should open drafts panel
|
|
5236
|
+
await openPublishPanel(el);
|
|
5237
|
+
requireElement(draftsRow(el), "expected drafts row").click();
|
|
5238
|
+
await flushUpdates(el);
|
|
3075
5239
|
|
|
3076
5240
|
expect(el._draftsPanelOpen).toBe(true);
|
|
3077
5241
|
|
|
@@ -3146,6 +5310,71 @@ describe("JantComposeDialog", () => {
|
|
|
3146
5310
|
expect(el._draftMenuOpenId).toBeNull();
|
|
3147
5311
|
});
|
|
3148
5312
|
|
|
5313
|
+
it("uses title or quote text for draft previews and identifies each format", async () => {
|
|
5314
|
+
const el = await createElement();
|
|
5315
|
+
el._draftsPanelOpen = true;
|
|
5316
|
+
el._draftsLoading = false;
|
|
5317
|
+
el._drafts = [
|
|
5318
|
+
{
|
|
5319
|
+
id: "pst_note",
|
|
5320
|
+
slug: "note-draft",
|
|
5321
|
+
format: "note",
|
|
5322
|
+
title: "A titled note",
|
|
5323
|
+
bodyText: "This body should not be the preview",
|
|
5324
|
+
bodyHtml: "<p>This body should not be the preview</p>",
|
|
5325
|
+
url: null,
|
|
5326
|
+
quoteText: null,
|
|
5327
|
+
replyToId: null,
|
|
5328
|
+
updatedAt: 0,
|
|
5329
|
+
mediaAttachments: [],
|
|
5330
|
+
},
|
|
5331
|
+
{
|
|
5332
|
+
id: "pst_quote",
|
|
5333
|
+
slug: "quote-draft",
|
|
5334
|
+
format: "quote",
|
|
5335
|
+
title: "Quote title",
|
|
5336
|
+
bodyText: "Supporting note",
|
|
5337
|
+
bodyHtml: "<p>Supporting note</p>",
|
|
5338
|
+
url: null,
|
|
5339
|
+
quoteText: "The quoted words come first.",
|
|
5340
|
+
replyToId: null,
|
|
5341
|
+
updatedAt: 0,
|
|
5342
|
+
mediaAttachments: [],
|
|
5343
|
+
},
|
|
5344
|
+
{
|
|
5345
|
+
id: "pst_link",
|
|
5346
|
+
slug: "link-draft",
|
|
5347
|
+
format: "link",
|
|
5348
|
+
title: null,
|
|
5349
|
+
bodyText: "A link note",
|
|
5350
|
+
bodyHtml: "<p>A link note</p>",
|
|
5351
|
+
url: "https://example.com",
|
|
5352
|
+
quoteText: null,
|
|
5353
|
+
replyToId: null,
|
|
5354
|
+
updatedAt: 0,
|
|
5355
|
+
mediaAttachments: [],
|
|
5356
|
+
},
|
|
5357
|
+
];
|
|
5358
|
+
await el.updateComplete;
|
|
5359
|
+
|
|
5360
|
+
const rows = el.querySelectorAll<HTMLElement>(".compose-draft-item");
|
|
5361
|
+
expect(rows).toHaveLength(3);
|
|
5362
|
+
expect(rows[0]?.querySelector(".compose-draft-preview")?.textContent).toBe(
|
|
5363
|
+
"A titled note",
|
|
5364
|
+
);
|
|
5365
|
+
expect(rows[1]?.querySelector(".compose-draft-preview")?.textContent).toBe(
|
|
5366
|
+
"The quoted words come first.",
|
|
5367
|
+
);
|
|
5368
|
+
expect(rows[2]?.querySelector(".compose-draft-preview")?.textContent).toBe(
|
|
5369
|
+
"A link note",
|
|
5370
|
+
);
|
|
5371
|
+
expect(
|
|
5372
|
+
Array.from(el.querySelectorAll(".compose-draft-format")).map(
|
|
5373
|
+
(format) => format.textContent,
|
|
5374
|
+
),
|
|
5375
|
+
).toEqual(["Note", "Quote", "Link"]);
|
|
5376
|
+
});
|
|
5377
|
+
|
|
3149
5378
|
it("does not dispatch submit when loading", async () => {
|
|
3150
5379
|
const el = await createElement();
|
|
3151
5380
|
el._loading = true;
|
|
@@ -3188,91 +5417,503 @@ describe("JantComposeDialog", () => {
|
|
|
3188
5417
|
"expected compose editor",
|
|
3189
5418
|
);
|
|
3190
5419
|
|
|
3191
|
-
// Simulate adding an attachment
|
|
3192
|
-
const blob = new Blob(["fake-image"], { type: "image/png" });
|
|
3193
|
-
const file = new File([blob], "test.png", { type: "image/png" });
|
|
3194
|
-
const previewUrl = URL.createObjectURL(blob);
|
|
5420
|
+
// Simulate adding an attachment
|
|
5421
|
+
const blob = new Blob(["fake-image"], { type: "image/png" });
|
|
5422
|
+
const file = new File([blob], "test.png", { type: "image/png" });
|
|
5423
|
+
const previewUrl = URL.createObjectURL(blob);
|
|
5424
|
+
|
|
5425
|
+
editor._attachments = [
|
|
5426
|
+
{
|
|
5427
|
+
clientId: "test-id-1",
|
|
5428
|
+
file,
|
|
5429
|
+
previewUrl,
|
|
5430
|
+
status: "done",
|
|
5431
|
+
progress: null,
|
|
5432
|
+
mediaId: "media-1",
|
|
5433
|
+
alt: "",
|
|
5434
|
+
error: null,
|
|
5435
|
+
posterUrl: null,
|
|
5436
|
+
remoteUrl: null,
|
|
5437
|
+
summary: null,
|
|
5438
|
+
chars: null,
|
|
5439
|
+
},
|
|
5440
|
+
];
|
|
5441
|
+
editor._attachmentOrder = ["test-id-1"];
|
|
5442
|
+
await editor.updateComplete;
|
|
5443
|
+
|
|
5444
|
+
// Thumbnail strip should be visible
|
|
5445
|
+
expect(editor.querySelector(".compose-attachments")).not.toBeNull();
|
|
5446
|
+
expect(editor.querySelector(".compose-attachment-thumb")).not.toBeNull();
|
|
5447
|
+
// ALT button should be visible
|
|
5448
|
+
expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
|
|
5449
|
+
// Media tool button should show inline "Add" label
|
|
5450
|
+
const mediaBtn =
|
|
5451
|
+
editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
|
|
5452
|
+
expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
|
|
5453
|
+
"Add",
|
|
5454
|
+
);
|
|
5455
|
+
|
|
5456
|
+
URL.revokeObjectURL(previewUrl);
|
|
5457
|
+
});
|
|
5458
|
+
|
|
5459
|
+
it("puts the single-post editor straight inside the one scroll region", async () => {
|
|
5460
|
+
const el = await createElement();
|
|
5461
|
+
|
|
5462
|
+
const scroller = requireElement(
|
|
5463
|
+
el.querySelector<HTMLElement>(".compose-scroll"),
|
|
5464
|
+
"expected compose scroll region",
|
|
5465
|
+
);
|
|
5466
|
+
const editor = requireElement(
|
|
5467
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
5468
|
+
"expected compose editor",
|
|
5469
|
+
);
|
|
5470
|
+
|
|
5471
|
+
// The rules that lay the editor out are keyed on
|
|
5472
|
+
// `.compose-scroll > jant-compose-editor`. Any wrapper breaks them —
|
|
5473
|
+
// including a `display: contents` one, which drops the box but not the DOM
|
|
5474
|
+
// parentage the selector matches on.
|
|
5475
|
+
expect(editor.parentElement).toBe(scroller);
|
|
5476
|
+
// The action row is the composer's pinned floor; it must stay outside.
|
|
5477
|
+
expect(scroller.querySelector(".compose-action-row")).toBeNull();
|
|
5478
|
+
expect(el.querySelector(".compose-action-row")).not.toBeNull();
|
|
5479
|
+
});
|
|
5480
|
+
|
|
5481
|
+
it("scrolls in exactly one place across every compose mode", async () => {
|
|
5482
|
+
const css = readComponentCss();
|
|
5483
|
+
|
|
5484
|
+
// `.compose-scroll` owns the scrolling for single-post, reply and thread
|
|
5485
|
+
// alike. Anything nested inside it that scrolls on its own is the bug this
|
|
5486
|
+
// replaced: three stacked scrollers, and previews shrunk to fit them.
|
|
5487
|
+
expect(css).toMatch(
|
|
5488
|
+
/\.compose-scroll\s*\{[\s\S]*flex:\s*1;[\s\S]*min-height:\s*0;[\s\S]*overflow-y:\s*auto;/,
|
|
5489
|
+
);
|
|
5490
|
+
expect(css).not.toMatch(/\.compose-body\s*\{[^}]*overflow-y:\s*auto;/);
|
|
5491
|
+
expect(css).not.toMatch(
|
|
5492
|
+
/\.compose-thread-compose-layout\s*\{[^}]*overflow-y:\s*auto;/,
|
|
5493
|
+
);
|
|
5494
|
+
expect(css).not.toMatch(
|
|
5495
|
+
/\.compose-editor-row\s+\.compose-attachments-dock\s*\{[^}]*overflow-y:\s*auto;/,
|
|
5496
|
+
);
|
|
5497
|
+
|
|
5498
|
+
// Nothing between the scroll region and the text may clip or claim a fixed
|
|
5499
|
+
// share of the height — every block is sized by its own content.
|
|
5500
|
+
expect(css).not.toMatch(
|
|
5501
|
+
/\.compose-editor-row\s*\{[^}]*overflow:\s*hidden;/,
|
|
5502
|
+
);
|
|
5503
|
+
expect(css).not.toMatch(
|
|
5504
|
+
/\.compose-thread-layout\s*\{[^}]*overflow:\s*hidden;/,
|
|
5505
|
+
);
|
|
5506
|
+
});
|
|
5507
|
+
|
|
5508
|
+
it("lets attachment previews take the room the pinned toolbar used to need", () => {
|
|
5509
|
+
const css = readComponentCss();
|
|
5510
|
+
|
|
5511
|
+
expect(css).toMatch(
|
|
5512
|
+
/\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
|
|
5513
|
+
);
|
|
5514
|
+
expect(css).toMatch(
|
|
5515
|
+
/\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
|
|
5516
|
+
);
|
|
5517
|
+
// The strip's row height is one variable every kind of attachment reads,
|
|
5518
|
+
// so the number is asserted where it is declared.
|
|
5519
|
+
expect(css).toMatch(
|
|
5520
|
+
/\.compose-attachments\s*\{[\s\S]*?--compose-strip-height:\s*min\(340px,\s*40dvh\);/,
|
|
5521
|
+
);
|
|
5522
|
+
expect(css).toMatch(
|
|
5523
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-strip-height\);/,
|
|
5524
|
+
);
|
|
5525
|
+
// Reply compose no longer shrinks its previews to 96×72 to spare the
|
|
5526
|
+
// toolbar — one preview size everywhere.
|
|
5527
|
+
expect(css).not.toMatch(/--compose-reply-attachment-width/);
|
|
5528
|
+
});
|
|
5529
|
+
|
|
5530
|
+
it("gives a card in the strip the same height as the pictures beside it", () => {
|
|
5531
|
+
const css = readComponentCss();
|
|
5532
|
+
|
|
5533
|
+
// A card is chrome, not content: it fills the height a picture sets, so a
|
|
5534
|
+
// mixed strip reads as one row and every control row lands on one line.
|
|
5535
|
+
expect(css).toMatch(
|
|
5536
|
+
/\.compose-attachments:has\(\.compose-attachment-img\)\s+\.compose-attachment:not\(:only-child\)\s+:is\(\s*\.compose-attachment-file-card,\s*\.compose-attachment-text-card,\s*\.compose-attachment-preview-fallback\s*\)\s*\{\s*height:\s*var\(--compose-strip-height\);/,
|
|
5537
|
+
);
|
|
5538
|
+
// With no picture to match, cards keep their own compact height rather
|
|
5539
|
+
// than growing into a row of empty boxes.
|
|
5540
|
+
expect(css).toMatch(
|
|
5541
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-file-card\s*\{[\s\S]*?height:\s*200px;/,
|
|
5542
|
+
);
|
|
5543
|
+
expect(css).toMatch(
|
|
5544
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-text-card\s*\{[\s\S]*?height:\s*200px;/,
|
|
5545
|
+
);
|
|
5546
|
+
});
|
|
5547
|
+
|
|
5548
|
+
it("routes a format change straight off the single-post editor", async () => {
|
|
5549
|
+
const el = await createElement();
|
|
5550
|
+
const editor = requireElement(
|
|
5551
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
5552
|
+
"expected compose editor",
|
|
5553
|
+
);
|
|
5554
|
+
|
|
5555
|
+
editor.dispatchEvent(
|
|
5556
|
+
new CustomEvent("jant:format-change", {
|
|
5557
|
+
detail: { format: "quote" },
|
|
5558
|
+
bubbles: true,
|
|
5559
|
+
}),
|
|
5560
|
+
);
|
|
5561
|
+
await flushUpdates(el);
|
|
5562
|
+
|
|
5563
|
+
expect(el._format).toBe("quote");
|
|
5564
|
+
});
|
|
5565
|
+
|
|
5566
|
+
it("shows the replied-to post at the same scale as the reply", () => {
|
|
5567
|
+
const css = readComponentCss();
|
|
5568
|
+
|
|
5569
|
+
// Same ceiling as the composer's own previews — the parent is not a
|
|
5570
|
+
// thumbnail strip.
|
|
5571
|
+
expect(css).toMatch(
|
|
5572
|
+
/\.compose-reply-context-body img,[\s\S]*?video\s*\{[\s\S]*?max-height:\s*min\(420px,\s*46dvh\);/,
|
|
5573
|
+
);
|
|
5574
|
+
// One rule governs every picture in the context. A second one keyed on
|
|
5575
|
+
// `.compose-reply-context-media-img` would sit at lower specificity than
|
|
5576
|
+
// the `…-body img` rule above it and never apply — the media strip is
|
|
5577
|
+
// inside the body.
|
|
5578
|
+
expect(css).not.toMatch(/\.compose-reply-context-media-img\s*\{/);
|
|
5579
|
+
|
|
5580
|
+
// Expanding hands the post its full height; a capped, separately scrolling
|
|
5581
|
+
// box is what left no room for full-size pictures.
|
|
5582
|
+
expect(css).toMatch(
|
|
5583
|
+
/\.compose-reply-context\.expanded\s*\{\s*max-height:\s*none;\s*overflow:\s*visible;/,
|
|
5584
|
+
);
|
|
5585
|
+
// Fullscreen's collapsed cap must not outrank that.
|
|
5586
|
+
expect(css).toMatch(
|
|
5587
|
+
/\.compose-fullscreen-thread-layout\s+\.compose-reply-context:not\(\.expanded\)\s*\{\s*max-height:\s*140px;/,
|
|
5588
|
+
);
|
|
5589
|
+
});
|
|
5590
|
+
|
|
5591
|
+
it("keeps reply compose tools inside the editor's text column", () => {
|
|
5592
|
+
const css = readComponentCss();
|
|
5593
|
+
|
|
5594
|
+
// `flex: 1` on the editor is horizontal — it claims the width the rail dot
|
|
5595
|
+
// leaves. Height comes from content.
|
|
5596
|
+
expect(css).toMatch(
|
|
5597
|
+
/\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*flex:\s*1;[\s\S]*min-width:\s*0;/,
|
|
5598
|
+
);
|
|
5599
|
+
expect(css).toMatch(
|
|
5600
|
+
/\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
|
|
5601
|
+
);
|
|
5602
|
+
expect(css).toMatch(
|
|
5603
|
+
/@media \(max-width:\s*699px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
|
|
5604
|
+
);
|
|
5605
|
+
expect(css).toMatch(
|
|
5606
|
+
/\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
|
|
5607
|
+
);
|
|
5608
|
+
// On the page the card's own padding is the text column, so every row
|
|
5609
|
+
// inside it reads a zero gutter and only restates its right.
|
|
5610
|
+
expect(css).toMatch(
|
|
5611
|
+
/\.compose-page-shell\s+\.compose-dialog-inner-page\s*\{[\s\S]*--compose-gutter:\s*0px;/,
|
|
5612
|
+
);
|
|
5613
|
+
expect(css).toMatch(
|
|
5614
|
+
/\.compose-page-shell\s+\.compose-dialog-inner-page\s+\.compose-thread-post-header\s*\{\s*padding-inline-end:\s*0;/,
|
|
5615
|
+
);
|
|
5616
|
+
expect(css).toMatch(
|
|
5617
|
+
/\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
|
|
5618
|
+
);
|
|
5619
|
+
expect(css).toMatch(
|
|
5620
|
+
/\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*calc\(var\(--type-content-body\) \* 1\.2\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
|
|
5621
|
+
);
|
|
5622
|
+
expect(css).toMatch(
|
|
5623
|
+
/\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
|
|
5624
|
+
);
|
|
5625
|
+
expect(css).toMatch(
|
|
5626
|
+
/\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
|
|
5627
|
+
);
|
|
5628
|
+
// The reply layout tunes only the lead-in. Height is the shared four-line
|
|
5629
|
+
// floor, which reads this layout's own leading and type size, so a
|
|
5630
|
+
// `min-height` here would just restate what the base rule already knows.
|
|
5631
|
+
expect(css).toMatch(
|
|
5632
|
+
/\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{\s*padding-top:\s*0\.35rem;\s*\}/,
|
|
5633
|
+
);
|
|
5634
|
+
expect(css).not.toMatch(
|
|
5635
|
+
/\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[^}]*min-height:/,
|
|
5636
|
+
);
|
|
5637
|
+
expect(css).toMatch(
|
|
5638
|
+
/\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
|
|
5639
|
+
);
|
|
5640
|
+
});
|
|
5641
|
+
|
|
5642
|
+
it("opens every compose text surface at a floor stated in lines", () => {
|
|
5643
|
+
const css = readComponentCss();
|
|
5644
|
+
|
|
5645
|
+
// Two lines for the editor, four for the quote — as multiples of the
|
|
5646
|
+
// leading, not pixels, so the floor follows the type instead of going
|
|
5647
|
+
// stale next to it.
|
|
5648
|
+
expect(css).toMatch(
|
|
5649
|
+
/\.compose-tiptap-body \.tiptap\s*\{[\s\S]*min-height:\s*calc\(var\(--type-body-leading\) \* 2em\);/,
|
|
5650
|
+
);
|
|
5651
|
+
expect(css).toMatch(
|
|
5652
|
+
/\.compose-quote-text\s*\{[\s\S]*min-height:\s*calc\(var\(--compose-quote-input-leading\) \* 4em\);/,
|
|
5653
|
+
);
|
|
5654
|
+
|
|
5655
|
+
// One floor for every editor: a note's body and the "thoughts" under a
|
|
5656
|
+
// link or a quote all open the same way.
|
|
5657
|
+
expect(css).not.toMatch(/\.compose-tiptap-thoughts/);
|
|
5658
|
+
});
|
|
5659
|
+
|
|
5660
|
+
it("runs the thread rail from the first dot to the last post's, and no further", () => {
|
|
5661
|
+
const css = readComponentCss();
|
|
5662
|
+
|
|
5663
|
+
// Drawn per row rather than as one strip down the layout, which is what
|
|
5664
|
+
// lets both ends be trimmed back to a dot.
|
|
5665
|
+
expect(css).not.toMatch(/\.compose-thread-layout::before\s*\{/);
|
|
5666
|
+
expect(css).toMatch(
|
|
5667
|
+
/\.compose-thread-layout > \*::before\s*\{[\s\S]*top:\s*0;[\s\S]*bottom:\s*0;/,
|
|
5668
|
+
);
|
|
5669
|
+
expect(css).toMatch(
|
|
5670
|
+
/\.compose-thread-layout > :first-child::before\s*\{\s*top:\s*var\(--compose-thread-dot-center\);/,
|
|
5671
|
+
);
|
|
5672
|
+
expect(css).toMatch(
|
|
5673
|
+
/\.compose-editor-row:not\(:has\(~ \.compose-editor-row\)\)::before\s*\{\s*bottom:\s*auto;\s*height:\s*var\(--compose-thread-dot-center\);/,
|
|
5674
|
+
);
|
|
5675
|
+
// The "add" row is a placeholder, not a post — the rail stops before it.
|
|
5676
|
+
expect(css).toMatch(
|
|
5677
|
+
/\.compose-thread-layout > \.compose-thread-add-row::before\s*\{\s*display:\s*none;/,
|
|
5678
|
+
);
|
|
5679
|
+
|
|
5680
|
+
// The dot reads its offset off the same centre the rail is trimmed to, so
|
|
5681
|
+
// resizing the marker cannot leave the line pointing past it.
|
|
5682
|
+
expect(css).toMatch(
|
|
5683
|
+
/\.compose-thread-dot\s*\{[\s\S]*margin-top:\s*calc\(\s*var\(--compose-thread-dot-center\) -\s*var\(--compose-thread-row-padding-top\) -\s*var\(--site-thread-marker-size\) \/ 2\s*\);/,
|
|
5684
|
+
);
|
|
5685
|
+
});
|
|
3195
5686
|
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
clientId: "test-id-1",
|
|
3199
|
-
file,
|
|
3200
|
-
previewUrl,
|
|
3201
|
-
status: "done",
|
|
3202
|
-
progress: null,
|
|
3203
|
-
mediaId: "media-1",
|
|
3204
|
-
alt: "",
|
|
3205
|
-
error: null,
|
|
3206
|
-
posterUrl: null,
|
|
3207
|
-
summary: null,
|
|
3208
|
-
chars: null,
|
|
3209
|
-
},
|
|
3210
|
-
];
|
|
3211
|
-
editor._attachmentOrder = ["test-id-1"];
|
|
3212
|
-
await editor.updateComplete;
|
|
5687
|
+
it("starts every compose row's ink on one text column", () => {
|
|
5688
|
+
const css = readComponentCss();
|
|
3213
5689
|
|
|
3214
|
-
//
|
|
3215
|
-
|
|
3216
|
-
expect(
|
|
3217
|
-
|
|
3218
|
-
expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
|
|
3219
|
-
// Media tool button should show inline "Add" label
|
|
3220
|
-
const mediaBtn =
|
|
3221
|
-
editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
|
|
3222
|
-
expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
|
|
3223
|
-
"Add",
|
|
5690
|
+
// One knob for the column itself, read by every row rather than restated
|
|
5691
|
+
// as a literal per row.
|
|
5692
|
+
expect(css).toMatch(
|
|
5693
|
+
/\.compose-dialog,\s*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-gutter:\s*20px;/,
|
|
3224
5694
|
);
|
|
5695
|
+
expect(css).toMatch(
|
|
5696
|
+
/@media \(min-width:\s*700px\)\s*\{[\s\S]*--compose-gutter:\s*24px;/,
|
|
5697
|
+
);
|
|
5698
|
+
// Zero wherever the indent already comes from outside the editor.
|
|
5699
|
+
expect(css).toMatch(
|
|
5700
|
+
/\.compose-editor-row\s*\{\s*\/\*[\s\S]*?\*\/\s*--compose-gutter:\s*0px;/,
|
|
5701
|
+
);
|
|
5702
|
+
|
|
5703
|
+
for (const rule of [
|
|
5704
|
+
/\.compose-body\s*\{[^}]*padding:\s*16px 20px 12px var\(--compose-gutter\);/,
|
|
5705
|
+
/\.compose-tools-row\s*\{[\s\S]*padding:\s*6px 10px 6px var\(--compose-gutter\);/,
|
|
5706
|
+
/\.compose-attachments-dock\s*\{\s*padding:\s*8px 20px 4px var\(--compose-gutter\);/,
|
|
5707
|
+
/\.compose-thread-post-header\s*\{[\s\S]*padding:\s*10px 16px 2px var\(--compose-gutter\);/,
|
|
5708
|
+
/\.compose-add-thread-trigger\s*\{\s*padding:\s*1px 10px 4px var\(--compose-gutter\);/,
|
|
5709
|
+
/\.compose-action-row\s*\{\s*padding:\s*10px 12px 14px var\(--compose-gutter\);/,
|
|
5710
|
+
]) {
|
|
5711
|
+
expect(css).toMatch(rule);
|
|
5712
|
+
}
|
|
3225
5713
|
|
|
3226
|
-
|
|
5714
|
+
// Each control hangs its box back out of the column by its own optical
|
|
5715
|
+
// inset, so the glyph or the label lands on the edge and not the box.
|
|
5716
|
+
expect(css).toMatch(
|
|
5717
|
+
/\.compose-tools-row\s*\{[\s\S]*--compose-tool-hang:\s*12px;/,
|
|
5718
|
+
);
|
|
5719
|
+
expect(css).toMatch(
|
|
5720
|
+
/\.compose-tools-row > :first-child\s*\{\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-tool-hang\)\);/,
|
|
5721
|
+
);
|
|
5722
|
+
expect(css).toMatch(
|
|
5723
|
+
/\.compose-thread-add-btn\s*\{[\s\S]*padding:\s*5px var\(--compose-add-hang\);\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-add-hang\)\);/,
|
|
5724
|
+
);
|
|
5725
|
+
expect(css).toMatch(
|
|
5726
|
+
/\.compose-collection-select\s*\{[\s\S]*margin-inline-start:\s*calc\(\s*-1 \* \(var\(--compose-collection-pad-icon\) \+ 1px\)\s*\);/,
|
|
5727
|
+
);
|
|
5728
|
+
|
|
5729
|
+
// The ad-hoc thread-only indents these replaced are gone — they were what
|
|
5730
|
+
// let each mode drift to its own edge in the first place.
|
|
5731
|
+
expect(css).not.toMatch(/margin-inline-start:\s*-10px;/);
|
|
5732
|
+
expect(css).not.toMatch(/\.compose-thread-layout \.compose-tools-row\s*\{/);
|
|
5733
|
+
// One "Add to thread" button, not two rule sets that have to stay in step.
|
|
5734
|
+
expect(css).not.toMatch(/\.compose-add-thread-btn/);
|
|
3227
5735
|
});
|
|
3228
5736
|
|
|
3229
|
-
it("
|
|
3230
|
-
const css =
|
|
5737
|
+
it("declares one marker vocabulary for the compose and reading rails", () => {
|
|
5738
|
+
const css = readComponentCss();
|
|
3231
5739
|
|
|
5740
|
+
// Both rails draw the same dot; only the surface behind it differs.
|
|
5741
|
+
// `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
|
|
5742
|
+
// the rule's closing brace and happily matches a later rule's declaration.
|
|
3232
5743
|
expect(css).toMatch(
|
|
3233
|
-
/\.
|
|
5744
|
+
/\.thread-group,\s*\.compose-thread-layout\s*\{\s*--site-thread-marker-size:\s*7px;[^}]*--site-thread-marker-gap:\s*9px;/,
|
|
3234
5745
|
);
|
|
3235
|
-
expect(css).toMatch(/\.compose-tools-row\s*\{[\s\S]*flex-shrink:\s*0;/);
|
|
3236
5746
|
expect(css).toMatch(
|
|
3237
|
-
/\.compose-
|
|
5747
|
+
/\.compose-thread-layout\s*\{\s*--site-thread-marker-surface:\s*var\(--compose-paper-bg\);\s*\}/,
|
|
5748
|
+
);
|
|
5749
|
+
// 7:1.5 marker-to-rail on both, from one token — compose used to hardcode
|
|
5750
|
+
// its own 1.5px while reading ran a 1px hairline under the same 7px dot.
|
|
5751
|
+
expect(css).toMatch(/--site-thread-rail-line-width:\s*1\.5px;/);
|
|
5752
|
+
expect(css).not.toMatch(
|
|
5753
|
+
/\.thread-group-preview,\s*\.thread-group-detail\s*\{[^}]*--site-thread-rail-line-width:/,
|
|
3238
5754
|
);
|
|
3239
5755
|
expect(css).toMatch(
|
|
3240
|
-
/\.compose-
|
|
5756
|
+
/\.compose-thread-layout > \*::before\s*\{[^}]*width:\s*var\(--site-thread-rail-line-width\);\s*margin-left:\s*calc\(var\(--site-thread-rail-line-width\) \/ -2\);/,
|
|
3241
5757
|
);
|
|
5758
|
+
// The reading rail's fade-in gradient stays — it softens where collapsed
|
|
5759
|
+
// ancestor context begins, which compose solves by trimming per row.
|
|
3242
5760
|
expect(css).toMatch(
|
|
3243
|
-
/\.
|
|
5761
|
+
/\.thread-group-preview::before,\s*\.thread-group-detail::before\s*\{\s*background:\s*linear-gradient\(/,
|
|
3244
5762
|
);
|
|
5763
|
+
// Both rails now stop at the last dot. Reading masks its group-wide line
|
|
5764
|
+
// from that dot down; `> ` keeps the mask off the last ancestor inside
|
|
5765
|
+
// `.thread-context-shell`, which the rail must run straight through.
|
|
3245
5766
|
expect(css).toMatch(
|
|
3246
|
-
/\.
|
|
5767
|
+
/\.thread-group > \.thread-item:last-child::after\s*\{[^}]*top:\s*50%;\s*bottom:\s*0;\s*width:\s*var\(--site-thread-rail-line-width\);\s*background-color:\s*var\(--site-thread-marker-surface\);/,
|
|
3247
5768
|
);
|
|
5769
|
+
expect(css).not.toMatch(/\.thread-group \.thread-item:last-child::after/);
|
|
5770
|
+
// The ring is reading's alone: the token is declared on `.thread-group`, and
|
|
5771
|
+
// compose's dot rule never reads it. The compose rail is quiet by design —
|
|
5772
|
+
// it already has a card edge, a format switcher and a "1/3" per row.
|
|
5773
|
+
expect(css).toMatch(
|
|
5774
|
+
/\.thread-group\s*\{[^}]*--site-thread-marker-ring-inset:\s*2px;\s*--site-thread-marker-ring-width:\s*2px;/,
|
|
5775
|
+
);
|
|
5776
|
+
expect(css).not.toMatch(
|
|
5777
|
+
/\.compose-thread-layout\s*\{[^}]*--site-thread-marker-ring/,
|
|
5778
|
+
);
|
|
5779
|
+
expect(css).not.toMatch(
|
|
5780
|
+
/\.compose-thread-dot::before\s*\{[^}]*--site-thread-(dot-ring|marker-ring)/,
|
|
5781
|
+
);
|
|
5782
|
+
// The per-dot border and the size-based hero markers stay gone: a border
|
|
5783
|
+
// eats into the fill instead of sitting outside it, and a bigger dot
|
|
5784
|
+
// repeats what the full card beside it already says.
|
|
5785
|
+
for (const dead of [
|
|
5786
|
+
"--site-thread-marker-border-width",
|
|
5787
|
+
"--site-thread-marker-hero-size",
|
|
5788
|
+
"--site-thread-marker-hero-border-width",
|
|
5789
|
+
]) {
|
|
5790
|
+
expect(css).not.toContain(dead);
|
|
5791
|
+
}
|
|
5792
|
+
expect(css).not.toMatch(/\.compose-thread-dot(::before)?\s*\{[^}]*border:/);
|
|
5793
|
+
});
|
|
5794
|
+
|
|
5795
|
+
it("punches the dot's gap with the surface colour on both rails", () => {
|
|
5796
|
+
const css = readComponentCss();
|
|
5797
|
+
|
|
5798
|
+
// Compose stops at the punch-out; reading layers its ring inside the same
|
|
5799
|
+
// radius, so the outermost layer — the one that breaks the rail line — is
|
|
5800
|
+
// still the gap at its full size on both rails.
|
|
5801
|
+
const punchOut =
|
|
5802
|
+
/0 0 0 var\(--site-thread-marker-gap\)\s*var\(--site-thread-marker-surface\);/;
|
|
3248
5803
|
expect(css).toMatch(
|
|
3249
|
-
|
|
5804
|
+
new RegExp(
|
|
5805
|
+
`\\.compose-thread-dot::before\\s*\\{[^}]*box-shadow:\\s*${punchOut.source}`,
|
|
5806
|
+
),
|
|
3250
5807
|
);
|
|
3251
5808
|
expect(css).toMatch(
|
|
3252
|
-
|
|
5809
|
+
new RegExp(`\\.thread-item::before\\s*\\{[^}]*${punchOut.source}`),
|
|
3253
5810
|
);
|
|
5811
|
+
// Reading's ring: a band of surface, then the accent tint, both drawn as
|
|
5812
|
+
// spread inside the punch. A border would eat the fill instead.
|
|
3254
5813
|
expect(css).toMatch(
|
|
3255
|
-
/\.
|
|
5814
|
+
/\.thread-item::before\s*\{[^}]*box-shadow:\s*0 0 0 var\(--site-thread-marker-ring-inset\)\s*var\(--site-thread-marker-surface\),\s*0 0 0\s*calc\(\s*var\(--site-thread-marker-ring-inset\) \+\s*var\(--site-thread-marker-ring-width\)\s*\)\s*var\(--site-thread-dot-ring\),/,
|
|
3256
5815
|
);
|
|
5816
|
+
expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*border:\s/);
|
|
5817
|
+
});
|
|
5818
|
+
|
|
5819
|
+
it("marks the post a view is about with an accent fill, not a bigger dot", () => {
|
|
5820
|
+
const css = readComponentCss();
|
|
5821
|
+
|
|
5822
|
+
// Feed hero, curated highlight and the opened detail post share one rule,
|
|
5823
|
+
// matching compose's `.is-current`. Size is not a second signal.
|
|
3257
5824
|
expect(css).toMatch(
|
|
3258
|
-
/\.
|
|
5825
|
+
/\.thread-item-hero::before,\s*\.thread-item-curated::before,\s*\.thread-item-current::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);\s*\}/,
|
|
3259
5826
|
);
|
|
5827
|
+
// Both rails take the current-post colour from one token, and that token is
|
|
5828
|
+
// a tint — the raw accent made a 7px dot the loudest thing on the page.
|
|
3260
5829
|
expect(css).toMatch(
|
|
3261
|
-
/\.compose-
|
|
5830
|
+
/\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);/,
|
|
3262
5831
|
);
|
|
3263
5832
|
expect(css).toMatch(
|
|
3264
|
-
|
|
5833
|
+
/--site-thread-marker-current:\s*color-mix\(\s*in srgb,\s*var\(--site-accent\) 90%,\s*var\(--site-threadline\)\s*\);/,
|
|
5834
|
+
);
|
|
5835
|
+
expect(css).not.toMatch(
|
|
5836
|
+
/\.thread-item-current::before\s*\{[^}]*var\(--site-accent\)[^-]/,
|
|
3265
5837
|
);
|
|
5838
|
+
// Every dot's geometry now derives from the shared token.
|
|
3266
5839
|
expect(css).toMatch(
|
|
3267
|
-
/\.
|
|
5840
|
+
/\.thread-item::before\s*\{[^}]*width:\s*var\(--site-thread-marker-size\);/,
|
|
3268
5841
|
);
|
|
5842
|
+
expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*width:\s*\d+px;/);
|
|
5843
|
+
});
|
|
5844
|
+
|
|
5845
|
+
it("centres every rail child on the line by giving them a shared track", () => {
|
|
5846
|
+
const css = readComponentCss();
|
|
5847
|
+
|
|
5848
|
+
// The line's x is derived from the track, never from a dot's own size — a
|
|
5849
|
+
// hardcoded half-marker is what put the line down the dot's edge before.
|
|
3269
5850
|
expect(css).toMatch(
|
|
3270
|
-
|
|
5851
|
+
/--compose-thread-rail-left:\s*calc\(\s*var\(--compose-thread-padding-left\) \+ var\(--compose-thread-rail-width\) \/ 2\s*\);/,
|
|
5852
|
+
);
|
|
5853
|
+
expect(css).not.toMatch(/--compose-thread-rail-left:[^;]*\+ \d/);
|
|
5854
|
+
|
|
5855
|
+
// Post dot: fills the track, centres the circle in it.
|
|
5856
|
+
expect(css).toMatch(
|
|
5857
|
+
/\.compose-thread-dot\s*\{[\s\S]*justify-content:\s*center;[\s\S]*width:\s*var\(--compose-thread-rail-width\);/,
|
|
5858
|
+
);
|
|
5859
|
+
// "Add to thread" placeholder: fills the track outright, so no offset.
|
|
5860
|
+
expect(css).toMatch(
|
|
5861
|
+
/\.compose-thread-add-dot\s*\{[\s\S]*width:\s*var\(--compose-thread-rail-width\);[\s\S]*height:\s*var\(--compose-thread-rail-width\);/,
|
|
5862
|
+
);
|
|
5863
|
+
expect(css).not.toMatch(/\.compose-thread-add-dot\s*\{[^}]*margin-left:/);
|
|
5864
|
+
// Reply meta hangs off the same track as the rows above it.
|
|
5865
|
+
expect(css).toMatch(
|
|
5866
|
+
/\.compose-reply-meta\s*\{[\s\S]*var\(--compose-thread-gap\) \+\s*var\(--compose-thread-rail-width\)/,
|
|
5867
|
+
);
|
|
5868
|
+
});
|
|
5869
|
+
|
|
5870
|
+
it("accents the dot of the post holding the cursor, not every editor row", () => {
|
|
5871
|
+
const css = readComponentCss();
|
|
5872
|
+
|
|
5873
|
+
// Which selector carries the highlight — the colour itself is pinned by
|
|
5874
|
+
// "marks the post a view is about…", so this stays token-agnostic.
|
|
5875
|
+
expect(css).toMatch(
|
|
5876
|
+
/\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:/,
|
|
5877
|
+
);
|
|
5878
|
+
// The old rule lit every post in a thread at once.
|
|
5879
|
+
expect(css).not.toMatch(
|
|
5880
|
+
/\.compose-editor-row \.compose-thread-dot\s*\{\s*background-color:/,
|
|
5881
|
+
);
|
|
5882
|
+
});
|
|
5883
|
+
|
|
5884
|
+
it("moves the current-post marker as focus moves between thread posts", async () => {
|
|
5885
|
+
const el = await createElement();
|
|
5886
|
+
el._threadItems = [
|
|
5887
|
+
{ id: "thread-1", format: "note" },
|
|
5888
|
+
{ id: "thread-2", format: "note" },
|
|
5889
|
+
{ id: "thread-3", format: "note" },
|
|
5890
|
+
];
|
|
5891
|
+
await flushUpdates(el);
|
|
5892
|
+
|
|
5893
|
+
const currentIndexes = () =>
|
|
5894
|
+
Array.from(el.querySelectorAll<HTMLElement>(".compose-thread-post"))
|
|
5895
|
+
.map((row, index) =>
|
|
5896
|
+
row.classList.contains("is-current") ? index : -1,
|
|
5897
|
+
)
|
|
5898
|
+
.filter((index) => index >= 0);
|
|
5899
|
+
|
|
5900
|
+
// Exactly one at a time — the old rule accented all three.
|
|
5901
|
+
expect(currentIndexes()).toEqual([0]);
|
|
5902
|
+
|
|
5903
|
+
const second = requireElement(
|
|
5904
|
+
el.querySelector<HTMLElement>(
|
|
5905
|
+
'.compose-thread-post[data-thread-index="1"]',
|
|
5906
|
+
),
|
|
5907
|
+
"expected the second thread post",
|
|
3271
5908
|
);
|
|
5909
|
+
second.dispatchEvent(new Event("focusin", { bubbles: true }));
|
|
5910
|
+
await flushUpdates(el);
|
|
5911
|
+
|
|
5912
|
+
expect(currentIndexes()).toEqual([1]);
|
|
3272
5913
|
});
|
|
3273
5914
|
|
|
3274
5915
|
it("keeps passive footnote references quiet until the editor selects them", () => {
|
|
3275
|
-
const css =
|
|
5916
|
+
const css = readComponentCss();
|
|
3276
5917
|
|
|
3277
5918
|
expect(css).toMatch(
|
|
3278
5919
|
/\.compose-tiptap-body \.tiptap \.tiptap-footnote-reference\s*\{[^}]*color:\s*var\(--site-footnote-marker\);/,
|
|
@@ -3304,6 +5945,7 @@ describe("JantComposeDialog", () => {
|
|
|
3304
5945
|
alt: "",
|
|
3305
5946
|
error: null,
|
|
3306
5947
|
posterUrl: null,
|
|
5948
|
+
remoteUrl: null,
|
|
3307
5949
|
summary: null,
|
|
3308
5950
|
chars: null,
|
|
3309
5951
|
},
|
|
@@ -3346,6 +5988,7 @@ describe("JantComposeDialog", () => {
|
|
|
3346
5988
|
alt: "",
|
|
3347
5989
|
error: null,
|
|
3348
5990
|
posterUrl: null,
|
|
5991
|
+
remoteUrl: null,
|
|
3349
5992
|
summary: null,
|
|
3350
5993
|
chars: null,
|
|
3351
5994
|
},
|
|
@@ -3401,6 +6044,7 @@ describe("JantComposeDialog", () => {
|
|
|
3401
6044
|
alt: "A test image",
|
|
3402
6045
|
error: null,
|
|
3403
6046
|
posterUrl: null,
|
|
6047
|
+
remoteUrl: null,
|
|
3404
6048
|
summary: null,
|
|
3405
6049
|
chars: null,
|
|
3406
6050
|
},
|
|
@@ -3418,8 +6062,7 @@ describe("JantComposeDialog", () => {
|
|
|
3418
6062
|
await editor.updateComplete;
|
|
3419
6063
|
|
|
3420
6064
|
let receivedDetail:
|
|
3421
|
-
|
|
3422
|
-
| null = null;
|
|
6065
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
3423
6066
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
3424
6067
|
const customEvent = event as CustomEvent<
|
|
3425
6068
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -3471,6 +6114,7 @@ describe("JantComposeDialog", () => {
|
|
|
3471
6114
|
alt: "Alt for pending",
|
|
3472
6115
|
error: null,
|
|
3473
6116
|
posterUrl: null,
|
|
6117
|
+
remoteUrl: null,
|
|
3474
6118
|
summary: null,
|
|
3475
6119
|
chars: null,
|
|
3476
6120
|
},
|
|
@@ -3546,7 +6190,116 @@ describe("JantComposeDialog", () => {
|
|
|
3546
6190
|
);
|
|
3547
6191
|
});
|
|
3548
6192
|
|
|
3549
|
-
// ──
|
|
6193
|
+
// ── Leaving compose ────────────────────────────────────────────────
|
|
6194
|
+
|
|
6195
|
+
it("names the dialog for assistive tech now that no title is drawn", async () => {
|
|
6196
|
+
// The visible title carried the dialog's accessible name; it moves to an
|
|
6197
|
+
// attribute rather than disappearing with the row.
|
|
6198
|
+
const dialog = document.createElement("dialog");
|
|
6199
|
+
document.body.appendChild(dialog);
|
|
6200
|
+
const el = document.createElement(
|
|
6201
|
+
"jant-compose-dialog",
|
|
6202
|
+
) as JantComposeDialog;
|
|
6203
|
+
el.collections = collections;
|
|
6204
|
+
el.labels = labels;
|
|
6205
|
+
dialog.appendChild(el);
|
|
6206
|
+
await flushUpdates(el);
|
|
6207
|
+
|
|
6208
|
+
expect(dialog.getAttribute("aria-label")).toBe(labels.composeDialogLabel);
|
|
6209
|
+
|
|
6210
|
+
// Tear the dialog down here: happy-dom throws when `body.innerHTML = ""`
|
|
6211
|
+
// in the shared beforeEach removes one it did not itself create.
|
|
6212
|
+
el.remove();
|
|
6213
|
+
dialog.remove();
|
|
6214
|
+
});
|
|
6215
|
+
|
|
6216
|
+
it("closes from the × in the post header row", async () => {
|
|
6217
|
+
const el = await createElement();
|
|
6218
|
+
await flushUpdates(el);
|
|
6219
|
+
const requestCloseSpy = vi.spyOn(el, "requestClose");
|
|
6220
|
+
|
|
6221
|
+
const closeBtn = requireElement(
|
|
6222
|
+
el.querySelector<HTMLButtonElement>(
|
|
6223
|
+
".compose-thread-post-header .compose-close-btn",
|
|
6224
|
+
),
|
|
6225
|
+
"expected close button in the post header",
|
|
6226
|
+
);
|
|
6227
|
+
expect(closeBtn.getAttribute("aria-label")).toBe(labels.close);
|
|
6228
|
+
|
|
6229
|
+
closeBtn.click();
|
|
6230
|
+
await flushUpdates(el);
|
|
6231
|
+
|
|
6232
|
+
expect(requestCloseSpy).toHaveBeenCalledTimes(1);
|
|
6233
|
+
});
|
|
6234
|
+
|
|
6235
|
+
it("hands the header slot back to per-post remove in a thread, and moves the exit to the quick actions row", async () => {
|
|
6236
|
+
const el = await createElement();
|
|
6237
|
+
el._threadItems = [
|
|
6238
|
+
{ id: "thread-1", format: "note" },
|
|
6239
|
+
{ id: "thread-2", format: "note" },
|
|
6240
|
+
];
|
|
6241
|
+
await flushUpdates(el);
|
|
6242
|
+
|
|
6243
|
+
// Each post owns its × for removing itself, so no close button competes.
|
|
6244
|
+
expect(el.querySelector(".compose-close-btn")).toBeNull();
|
|
6245
|
+
expect(
|
|
6246
|
+
el.querySelectorAll(".compose-thread-post-remove").length,
|
|
6247
|
+
).toBeGreaterThan(0);
|
|
6248
|
+
|
|
6249
|
+
const closeBtn = requireElement(
|
|
6250
|
+
el.querySelector<HTMLButtonElement>(
|
|
6251
|
+
".compose-quick-actions-row .compose-quick-actions-close",
|
|
6252
|
+
),
|
|
6253
|
+
"expected a close button in the quick actions row",
|
|
6254
|
+
);
|
|
6255
|
+
expect(closeBtn.textContent?.trim()).toBe(labels.close);
|
|
6256
|
+
|
|
6257
|
+
const requestCloseSpy = vi.spyOn(el, "requestClose");
|
|
6258
|
+
closeBtn.click();
|
|
6259
|
+
await flushUpdates(el);
|
|
6260
|
+
|
|
6261
|
+
expect(requestCloseSpy).toHaveBeenCalledTimes(1);
|
|
6262
|
+
});
|
|
6263
|
+
|
|
6264
|
+
it("keeps the thread exit out of single-post mode, where the × already offers one", async () => {
|
|
6265
|
+
const el = await createElement();
|
|
6266
|
+
expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
|
|
6267
|
+
|
|
6268
|
+
await openPublishPanel(el);
|
|
6269
|
+
const titles = Array.from(
|
|
6270
|
+
el.querySelectorAll<HTMLElement>(
|
|
6271
|
+
".compose-publish-panel .compose-sheet-title",
|
|
6272
|
+
),
|
|
6273
|
+
).map((n) => n.textContent?.trim());
|
|
6274
|
+
|
|
6275
|
+
// The exit is never a row in the options sheet, in either mode.
|
|
6276
|
+
expect(titles).not.toContain(labels.close);
|
|
6277
|
+
expect(titles).toContain("Drafts");
|
|
6278
|
+
});
|
|
6279
|
+
|
|
6280
|
+
it("keeps the exit visible in a thread whose visibility row is locked", async () => {
|
|
6281
|
+
const el = await createElement();
|
|
6282
|
+
el._threadItems = [
|
|
6283
|
+
{ id: "thread-1", format: "note" },
|
|
6284
|
+
{ id: "thread-2", format: "note" },
|
|
6285
|
+
];
|
|
6286
|
+
el._visibilityLocked = true;
|
|
6287
|
+
await flushUpdates(el);
|
|
6288
|
+
|
|
6289
|
+
expect(el.querySelector(".compose-quick-actions-close")).not.toBeNull();
|
|
6290
|
+
});
|
|
6291
|
+
|
|
6292
|
+
it("leaves the exit to the page's own Back link in page mode", async () => {
|
|
6293
|
+
const el = await createElement();
|
|
6294
|
+
el.pageMode = true;
|
|
6295
|
+
el._threadItems = [
|
|
6296
|
+
{ id: "thread-1", format: "note" },
|
|
6297
|
+
{ id: "thread-2", format: "note" },
|
|
6298
|
+
];
|
|
6299
|
+
await flushUpdates(el);
|
|
6300
|
+
|
|
6301
|
+
expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
|
|
6302
|
+
});
|
|
3550
6303
|
|
|
3551
6304
|
it("requestClose on empty form closes immediately without confirmation", async () => {
|
|
3552
6305
|
const el = await createElement();
|
|
@@ -4541,6 +7294,66 @@ describe("JantComposeDialog", () => {
|
|
|
4541
7294
|
expect(el.querySelector("[data-compose-link-title-error]")).toBeNull();
|
|
4542
7295
|
});
|
|
4543
7296
|
|
|
7297
|
+
it("explains the missing link title once publish is attempted", async () => {
|
|
7298
|
+
const el = await createElement();
|
|
7299
|
+
el._format = "link";
|
|
7300
|
+
await el.updateComplete;
|
|
7301
|
+
|
|
7302
|
+
const editor = requireElement(
|
|
7303
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
7304
|
+
"expected compose editor",
|
|
7305
|
+
);
|
|
7306
|
+
editor._url = "https://example.com";
|
|
7307
|
+
await editor.updateComplete;
|
|
7308
|
+
|
|
7309
|
+
el.dispatchEvent(
|
|
7310
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
7311
|
+
key: "Enter",
|
|
7312
|
+
metaKey: true,
|
|
7313
|
+
bubbles: true,
|
|
7314
|
+
}),
|
|
7315
|
+
);
|
|
7316
|
+
await el.updateComplete;
|
|
7317
|
+
await editor.updateComplete;
|
|
7318
|
+
|
|
7319
|
+
const error = requireElement(
|
|
7320
|
+
el.querySelector("[data-compose-link-title-error]"),
|
|
7321
|
+
"expected link title error",
|
|
7322
|
+
);
|
|
7323
|
+
expect(error.textContent?.trim()).toBe(
|
|
7324
|
+
"Add a title before posting this link.",
|
|
7325
|
+
);
|
|
7326
|
+
});
|
|
7327
|
+
|
|
7328
|
+
it("explains the missing link URL once publish is attempted", async () => {
|
|
7329
|
+
const el = await createElement();
|
|
7330
|
+
el._format = "link";
|
|
7331
|
+
await el.updateComplete;
|
|
7332
|
+
|
|
7333
|
+
const editor = requireElement(
|
|
7334
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
7335
|
+
"expected compose editor",
|
|
7336
|
+
);
|
|
7337
|
+
|
|
7338
|
+
el.dispatchEvent(
|
|
7339
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
7340
|
+
key: "Enter",
|
|
7341
|
+
metaKey: true,
|
|
7342
|
+
bubbles: true,
|
|
7343
|
+
}),
|
|
7344
|
+
);
|
|
7345
|
+
await el.updateComplete;
|
|
7346
|
+
await editor.updateComplete;
|
|
7347
|
+
|
|
7348
|
+
const error = requireElement(
|
|
7349
|
+
el.querySelector("[data-compose-url-status]"),
|
|
7350
|
+
"expected link url error",
|
|
7351
|
+
);
|
|
7352
|
+
expect(error.textContent?.trim()).toBe(
|
|
7353
|
+
"Add a URL before posting this link.",
|
|
7354
|
+
);
|
|
7355
|
+
});
|
|
7356
|
+
|
|
4544
7357
|
it("Cmd/Ctrl+Enter finishes an attached text editor instead of publishing", async () => {
|
|
4545
7358
|
const el = await createElement();
|
|
4546
7359
|
(
|
|
@@ -4754,7 +7567,7 @@ describe("JantComposeDialog", () => {
|
|
|
4754
7567
|
).toBe("Don't save");
|
|
4755
7568
|
expect(
|
|
4756
7569
|
el.querySelector(".compose-confirm-cancel")?.textContent?.trim(),
|
|
4757
|
-
).toBe("
|
|
7570
|
+
).toBe("Keep editing");
|
|
4758
7571
|
});
|
|
4759
7572
|
|
|
4760
7573
|
it("cancel on attached text confirm returns focus to the attached editor", async () => {
|
|
@@ -5008,9 +7821,12 @@ describe("JantComposeDialog", () => {
|
|
|
5008
7821
|
el._format = "note";
|
|
5009
7822
|
await el.updateComplete;
|
|
5010
7823
|
|
|
5011
|
-
// Format switcher should be visible
|
|
7824
|
+
// Format switcher should be visible, and the submit label says this is a
|
|
7825
|
+
// new post rather than an edit.
|
|
5012
7826
|
expect(el.querySelector(".compose-segmented")).not.toBeNull();
|
|
5013
|
-
expect(el.querySelector(".compose-
|
|
7827
|
+
expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
|
|
7828
|
+
"Post",
|
|
7829
|
+
);
|
|
5014
7830
|
|
|
5015
7831
|
// Button should say "Post", not "Done"
|
|
5016
7832
|
const postBtn = requireElement(
|
|
@@ -5095,11 +7911,94 @@ describe("JantComposeDialog", () => {
|
|
|
5095
7911
|
expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
|
|
5096
7912
|
"draft789",
|
|
5097
7913
|
);
|
|
7914
|
+
expect(
|
|
7915
|
+
(receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
|
|
7916
|
+
).toBe("draft789");
|
|
5098
7917
|
expect((receivedDetail as unknown as ComposeSubmitDetail).status).toBe(
|
|
5099
7918
|
"published",
|
|
5100
7919
|
);
|
|
5101
7920
|
});
|
|
5102
7921
|
|
|
7922
|
+
it("opens a thread draft without deleting it and preserves its draft identity", async () => {
|
|
7923
|
+
const rootId = "pst_draft_root";
|
|
7924
|
+
const replyId = "pst_draft_reply";
|
|
7925
|
+
const requests: Array<{ url: string; method: string }> = [];
|
|
7926
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
|
7927
|
+
const url = String(input);
|
|
7928
|
+
requests.push({ url, method: init?.method ?? "GET" });
|
|
7929
|
+
|
|
7930
|
+
if (url === `/api/posts/${rootId}`) {
|
|
7931
|
+
return new Response(
|
|
7932
|
+
JSON.stringify({
|
|
7933
|
+
id: rootId,
|
|
7934
|
+
threadId: rootId,
|
|
7935
|
+
format: "note",
|
|
7936
|
+
status: "draft",
|
|
7937
|
+
slug: "draft-thread",
|
|
7938
|
+
title: "Draft thread",
|
|
7939
|
+
body: null,
|
|
7940
|
+
attachments: [],
|
|
7941
|
+
collectionIds: [],
|
|
7942
|
+
}),
|
|
7943
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
7944
|
+
);
|
|
7945
|
+
}
|
|
7946
|
+
|
|
7947
|
+
if (url === "/api/posts?status=draft&limit=50") {
|
|
7948
|
+
return new Response(
|
|
7949
|
+
JSON.stringify({
|
|
7950
|
+
posts: [
|
|
7951
|
+
{
|
|
7952
|
+
id: replyId,
|
|
7953
|
+
threadId: rootId,
|
|
7954
|
+
replyToId: rootId,
|
|
7955
|
+
format: "quote",
|
|
7956
|
+
status: "draft",
|
|
7957
|
+
quoteText: "Draft reply",
|
|
7958
|
+
attachments: [],
|
|
7959
|
+
},
|
|
7960
|
+
],
|
|
7961
|
+
}),
|
|
7962
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
7963
|
+
);
|
|
7964
|
+
}
|
|
7965
|
+
|
|
7966
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
7967
|
+
});
|
|
7968
|
+
|
|
7969
|
+
const el = await createElement();
|
|
7970
|
+
await el.openDraft(rootId);
|
|
7971
|
+
await flushUpdates(el);
|
|
7972
|
+
|
|
7973
|
+
expect(el._draftSourceId).toBe(rootId);
|
|
7974
|
+
expect(el.querySelectorAll("jant-compose-editor")).toHaveLength(2);
|
|
7975
|
+
expect(requests).toEqual([
|
|
7976
|
+
{ url: `/api/posts/${rootId}`, method: "GET" },
|
|
7977
|
+
{ url: "/api/posts?status=draft&limit=50", method: "GET" },
|
|
7978
|
+
]);
|
|
7979
|
+
|
|
7980
|
+
let receivedDetail: ComposeSubmitDetail | null = null;
|
|
7981
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
7982
|
+
receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
7983
|
+
});
|
|
7984
|
+
requireElement(
|
|
7985
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
7986
|
+
"expected publish button",
|
|
7987
|
+
).click();
|
|
7988
|
+
|
|
7989
|
+
expect(receivedDetail).not.toBeNull();
|
|
7990
|
+
expect(
|
|
7991
|
+
(receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
|
|
7992
|
+
).toBe(rootId);
|
|
7993
|
+
expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
|
|
7994
|
+
rootId,
|
|
7995
|
+
);
|
|
7996
|
+
expect(
|
|
7997
|
+
(receivedDetail as unknown as ComposeSubmitDetail).threadPosts,
|
|
7998
|
+
).toHaveLength(2);
|
|
7999
|
+
expect(requests.every((request) => request.method === "GET")).toBe(true);
|
|
8000
|
+
});
|
|
8001
|
+
|
|
5103
8002
|
it("draft button confirm save dispatches draft then opens drafts panel", async () => {
|
|
5104
8003
|
const el = await createElement();
|
|
5105
8004
|
const editor = requireElement(
|
|
@@ -5129,13 +8028,10 @@ describe("JantComposeDialog", () => {
|
|
|
5129
8028
|
receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
5130
8029
|
});
|
|
5131
8030
|
|
|
5132
|
-
// Click
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
);
|
|
5137
|
-
draftBtn.click();
|
|
5138
|
-
await el.updateComplete;
|
|
8031
|
+
// Click the drafts row in the options panel → confirm panel
|
|
8032
|
+
await openPublishPanel(el);
|
|
8033
|
+
requireElement(draftsRow(el), "expected drafts row").click();
|
|
8034
|
+
await flushUpdates(el);
|
|
5139
8035
|
expect(el._confirmPanelOpen).toBe(true);
|
|
5140
8036
|
|
|
5141
8037
|
// Click "Save"
|
|
@@ -5200,13 +8096,10 @@ describe("JantComposeDialog", () => {
|
|
|
5200
8096
|
submitFired = true;
|
|
5201
8097
|
});
|
|
5202
8098
|
|
|
5203
|
-
// Click
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
);
|
|
5208
|
-
draftBtn.click();
|
|
5209
|
-
await el.updateComplete;
|
|
8099
|
+
// Click the drafts row in the options panel → confirm panel
|
|
8100
|
+
await openPublishPanel(el);
|
|
8101
|
+
requireElement(draftsRow(el), "expected drafts row").click();
|
|
8102
|
+
await flushUpdates(el);
|
|
5210
8103
|
|
|
5211
8104
|
// Click "Don't save"
|
|
5212
8105
|
requireElement(
|
|
@@ -5248,6 +8141,7 @@ describe("JantComposeDialog", () => {
|
|
|
5248
8141
|
alt: "",
|
|
5249
8142
|
error: null,
|
|
5250
8143
|
posterUrl: null,
|
|
8144
|
+
remoteUrl: null,
|
|
5251
8145
|
summary: null,
|
|
5252
8146
|
chars: null,
|
|
5253
8147
|
},
|