@jant/core 0.6.6 → 0.6.8

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.
Files changed (112) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -36,6 +36,7 @@ import {
36
36
  getSelectedFirstOrder,
37
37
  } from "../collection-picker-order.js";
38
38
  import type { JantComposeEditor } from "./jant-compose-editor.js";
39
+ import { convertComposeFormat } from "./compose-format-convert.js";
39
40
  import { getMediaCategory } from "../../lib/upload.js";
40
41
  import { getSlugValidationIssue } from "../../lib/slug-format.js";
41
42
  import { createTiptapEditor } from "../tiptap/create-editor.js";
@@ -666,7 +667,7 @@ export class JantComposeDialog extends LitElement {
666
667
  super();
667
668
  this.collections = [];
668
669
  this.labels = {} as ComposeLabels;
669
- this.uploadMaxFileSize = 500;
670
+ this.uploadMaxFileSize = 1024;
670
671
  this.pageMode = false;
671
672
  this.closeHref = "/";
672
673
  this.autoRestoreDraft = false;
@@ -743,13 +744,14 @@ export class JantComposeDialog extends LitElement {
743
744
  this._scheduleCollectionPickerAutofocus();
744
745
  }
745
746
  if (
746
- changed.has("_format") ||
747
747
  changed.has("_collectionIds") ||
748
748
  changed.has("_slug") ||
749
749
  changed.has("_publishedAtInput") ||
750
750
  changed.has("_visibility")
751
751
  ) {
752
- // Schedule draft auto-save (new-post and edit modes, not draft-load)
752
+ // Schedule draft auto-save (new-post and edit modes, not draft-load).
753
+ // `_format` is intentionally excluded: a bare format switch is exploratory
754
+ // and shouldn't persist a draft on its own (see `_switchFormat`).
753
755
  if (!this._draftSourceId) {
754
756
  this._scheduleDraftSave();
755
757
  }
@@ -2269,6 +2271,10 @@ export class JantComposeDialog extends LitElement {
2269
2271
 
2270
2272
  private _handleDialogCancel = (e: Event) => {
2271
2273
  e.preventDefault();
2274
+ // Defensive: some browsers dispatch <dialog> `cancel` for Escape even
2275
+ // when the IME consumed it. Mirror the guard from _handleKeydown.
2276
+ const ke = e as Partial<globalThis.KeyboardEvent>;
2277
+ if (ke.isComposing || ke.keyCode === 229) return;
2272
2278
  if (this._shouldIgnoreEscapeClose()) return;
2273
2279
  if (this._dismissEscapeOverlay()) return;
2274
2280
  this.requestClose();
@@ -2411,6 +2417,10 @@ export class JantComposeDialog extends LitElement {
2411
2417
 
2412
2418
  private _handleKeydown = (e: Event) => {
2413
2419
  const ke = e as globalThis.KeyboardEvent;
2420
+ // Let IME consume keys during composition (e.g. CJK candidate selection).
2421
+ // Without this, pressing Escape to dismiss the IME popup would trigger the
2422
+ // "Save to drafts?" prompt. See GitHub issue #120.
2423
+ if (ke.isComposing || ke.keyCode === 229) return;
2414
2424
  if (ke.key !== "Escape") {
2415
2425
  this._clearFilePickerEscapeState();
2416
2426
  }
@@ -2448,7 +2458,7 @@ export class JantComposeDialog extends LitElement {
2448
2458
  "jant-compose-editor",
2449
2459
  )[this._focusedThreadIndex];
2450
2460
  editor?.dispatchEvent(
2451
- new CustomEvent("jant:thread-format-change", {
2461
+ new CustomEvent("jant:format-change", {
2452
2462
  detail: { format: target },
2453
2463
  bubbles: true,
2454
2464
  }),
@@ -3044,6 +3054,14 @@ export class JantComposeDialog extends LitElement {
3044
3054
  const editor = this._editor;
3045
3055
  if (!editor) return;
3046
3056
 
3057
+ // Only persist genuine unsaved changes. Without this, merely opening a post
3058
+ // for edit (or restoring a draft) would write a local draft of the
3059
+ // unchanged content, since loading fires content-change events.
3060
+ if (!this._hasUnsavedChanges()) {
3061
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3062
+ return;
3063
+ }
3064
+
3047
3065
  const data = editor.getData();
3048
3066
  const hasContent =
3049
3067
  !!data.body ||
@@ -3055,7 +3073,7 @@ export class JantComposeDialog extends LitElement {
3055
3073
  data.attachedTexts.length > 0;
3056
3074
 
3057
3075
  if (!hasContent) {
3058
- globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
3076
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3059
3077
  return;
3060
3078
  }
3061
3079
 
@@ -3090,7 +3108,7 @@ export class JantComposeDialog extends LitElement {
3090
3108
 
3091
3109
  try {
3092
3110
  globalThis.localStorage.setItem(
3093
- JantComposeDialog._DRAFT_KEY,
3111
+ this._currentDraftStorageKey(),
3094
3112
  JSON.stringify(draft),
3095
3113
  );
3096
3114
  } catch {
@@ -3541,10 +3559,43 @@ export class JantComposeDialog extends LitElement {
3541
3559
 
3542
3560
  private static readonly _FORMATS: ComposeFormat[] = ["note", "link", "quote"];
3543
3561
 
3562
+ /**
3563
+ * Whether a format switch should convert fields (fold/extract). Only when
3564
+ * editing an existing post or a server draft — for a brand-new post, switching
3565
+ * just hides/shows fields and nothing is persisted yet, so conversion would
3566
+ * pollute the body for no benefit.
3567
+ */
3568
+ private _shouldConvertOnFormatSwitch(): boolean {
3569
+ return !!(this._editPostId || this._draftSourceId);
3570
+ }
3571
+
3544
3572
  private _switchFormat(target: ComposeFormat) {
3545
3573
  if (this._format === target) return;
3546
- if (this._editPostId) return;
3574
+ const editor = this._editor;
3575
+ if (editor && this._shouldConvertOnFormatSwitch()) {
3576
+ // Fold fields the target can't hold into the body before the format
3577
+ // change recreates the editor from `_bodyJson`. Synchronous, so the old
3578
+ // Tiptap instance can't fire onUpdate and clobber what we just wrote.
3579
+ // `applyConvertedFields` suppresses the one content-change event the
3580
+ // conversion emits, so the switch itself never schedules a draft save.
3581
+ editor.applyConvertedFields(
3582
+ convertComposeFormat(
3583
+ this._format,
3584
+ target,
3585
+ editor.getConvertibleFields(),
3586
+ ),
3587
+ );
3588
+ }
3589
+ // A bare format switch shouldn't persist a local draft, so drop any save
3590
+ // already pending from loading the post.
3591
+ this._cancelDraftSaveTimer();
3547
3592
  this._format = target;
3593
+ // Sync the editor's format this tick. Lit commits the `.format` binding
3594
+ // only after this render returns, so `_canPublish()` (which reads
3595
+ // `editor.getData()`, keyed on the editor's format) would otherwise compute
3596
+ // against the stale format for one render and leave the submit button
3597
+ // wrongly disabled.
3598
+ if (editor) editor.format = target;
3548
3599
  this._showPublishPanel = false;
3549
3600
  if (this._shouldAutofocusFormatInput()) {
3550
3601
  globalThis.requestAnimationFrame(() => this._editor?.focusInput());
@@ -3563,6 +3614,16 @@ export class JantComposeDialog extends LitElement {
3563
3614
  const draftButtonLabel = this._hasContent()
3564
3615
  ? this.labels.saveAsDraft
3565
3616
  : this.labels.drafts;
3617
+ // Format selector sits inline (above each post) whenever more than one post
3618
+ // is on screen — a reply (parent shown above) or a multi-post thread. The
3619
+ // header then shows a plain title instead of the format selector.
3620
+ const isReply = !!(this._replyToId && this._replyToData);
3621
+ const showTitle = isReply || this._threadItems.length > 0;
3622
+ const headerTitle = this._editPostId
3623
+ ? this.labels.editTitle
3624
+ : isReply
3625
+ ? this.labels.replyTitle
3626
+ : this.labels.newThread;
3566
3627
 
3567
3628
  return html`
3568
3629
  <header class="compose-dialog-header">
@@ -3575,39 +3636,33 @@ export class JantComposeDialog extends LitElement {
3575
3636
  </button>
3576
3637
 
3577
3638
  <div class="compose-dialog-header-center">
3578
- ${this._editPostId
3579
- ? html`<span class="compose-dialog-title"
3580
- >${this.labels.editPost}</span
3581
- >`
3582
- : this._threadItems.length > 0
3583
- ? html`<span class="compose-dialog-title"
3584
- >${this.labels.newThread}</span
3585
- >`
3586
- : html`
3587
- <div class="compose-segmented">
3588
- <div
3589
- class=${classMap({
3590
- "compose-format-pill": true,
3591
- "compose-format-pill-link": this._format === "link",
3592
- "compose-format-pill-quote": this._format === "quote",
3593
- })}
3594
- ></div>
3595
- ${formats.map(
3596
- (f) => html`
3597
- <button
3598
- type="button"
3599
- class=${classMap({
3600
- "compose-segmented-item": true,
3601
- "compose-segmented-item-active": this._format === f,
3602
- })}
3603
- @click=${() => this._switchFormat(f)}
3604
- >
3605
- ${formatLabels[f]}
3606
- </button>
3607
- `,
3608
- )}
3609
- </div>
3610
- `}
3639
+ ${showTitle
3640
+ ? html`<span class="compose-dialog-title">${headerTitle}</span>`
3641
+ : html`
3642
+ <div class="compose-segmented">
3643
+ <div
3644
+ class=${classMap({
3645
+ "compose-format-pill": true,
3646
+ "compose-format-pill-link": this._format === "link",
3647
+ "compose-format-pill-quote": this._format === "quote",
3648
+ })}
3649
+ ></div>
3650
+ ${formats.map(
3651
+ (f) => html`
3652
+ <button
3653
+ type="button"
3654
+ class=${classMap({
3655
+ "compose-segmented-item": true,
3656
+ "compose-segmented-item-active": this._format === f,
3657
+ })}
3658
+ @click=${() => this._switchFormat(f)}
3659
+ >
3660
+ ${formatLabels[f]}
3661
+ </button>
3662
+ `,
3663
+ )}
3664
+ </div>
3665
+ `}
3611
3666
  </div>
3612
3667
 
3613
3668
  <div class="compose-dialog-header-actions">
@@ -5289,9 +5344,7 @@ export class JantComposeDialog extends LitElement {
5289
5344
  @focusin=${() => {
5290
5345
  this._focusedThreadIndex = index;
5291
5346
  }}
5292
- @jant:thread-format-change=${(
5293
- e: CustomEvent<{ format: ComposeFormat }>,
5294
- ) => {
5347
+ @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
5295
5348
  e.stopPropagation();
5296
5349
  this._threadItems = this._threadItems.map((it, i) =>
5297
5350
  i === index ? { ...it, format: e.detail.format } : it,
@@ -5415,6 +5468,7 @@ export class JantComposeDialog extends LitElement {
5415
5468
  .format=${this._format}
5416
5469
  .labels=${this.labels}
5417
5470
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5471
+ .inlineFormat=${isReply}
5418
5472
  .slashCommandDiscovered=${this.slashCommandDiscovered}
5419
5473
  ></jant-compose-editor>`;
5420
5474
 
@@ -5438,7 +5492,15 @@ export class JantComposeDialog extends LitElement {
5438
5492
  ? html`
5439
5493
  <div class="compose-thread-layout">
5440
5494
  ${this._renderReplyContext()}
5441
- <div class="compose-editor-row">
5495
+ <div
5496
+ class="compose-editor-row"
5497
+ @jant:format-change=${(
5498
+ e: CustomEvent<{ format: ComposeFormat }>,
5499
+ ) => {
5500
+ e.stopPropagation();
5501
+ this._switchFormat(e.detail.format);
5502
+ }}
5503
+ >
5442
5504
  <div class="compose-thread-dot"></div>
5443
5505
  ${editor}
5444
5506
  </div>
@@ -30,6 +30,7 @@ import type {
30
30
  ComposeEditorSelection,
31
31
  ComposeFullscreenOpenDetail,
32
32
  } from "./compose-types.js";
33
+ import type { ComposeConvertFields } from "./compose-format-convert.js";
33
34
  import {
34
35
  UPLOAD_ACCEPT,
35
36
  getMediaCategory,
@@ -178,6 +179,7 @@ export class JantComposeEditor extends LitElement {
178
179
  uploadMaxFileSize: { type: Number },
179
180
  threadItem: { type: Boolean, attribute: "thread-item" },
180
181
  removable: { type: Boolean },
182
+ inlineFormat: { type: Boolean, attribute: "inline-format" },
181
183
  slashCommandDiscovered: { type: Boolean },
182
184
  _title: { state: true },
183
185
  _bodyJson: { state: true },
@@ -203,6 +205,7 @@ export class JantComposeEditor extends LitElement {
203
205
  declare uploadMaxFileSize: number;
204
206
  declare threadItem: boolean;
205
207
  declare removable: boolean;
208
+ declare inlineFormat: boolean;
206
209
  declare slashCommandDiscovered: boolean;
207
210
  declare _title: string;
208
211
  declare _bodyJson: JSONContent | null;
@@ -234,6 +237,14 @@ export class JantComposeEditor extends LitElement {
234
237
  private _scrollBufferApplied = false;
235
238
  private _filePickerCleanup: (() => void) | null = null;
236
239
  private _suppressAttachedTextOpenUntil = 0;
240
+ /**
241
+ * Set by {@link applyConvertedFields} so the format-conversion content writes
242
+ * don't emit a content-changed event (which would schedule a draft autosave).
243
+ * A bare format switch shouldn't persist a local draft — see `_switchFormat`.
244
+ * Always consumed: a switch also changes `format`, so `updated()` is guaranteed
245
+ * to run this cycle.
246
+ */
247
+ private _suppressContentChangedOnce = false;
237
248
  #inlineImageUploadGeneration = 0;
238
249
  #inlineImageUploadPromises = new Set<Promise<void>>();
239
250
  #sortable: { destroy(): void } | null = null;
@@ -247,9 +258,10 @@ export class JantComposeEditor extends LitElement {
247
258
  super();
248
259
  this.format = "note";
249
260
  this.labels = {} as ComposeLabels;
250
- this.uploadMaxFileSize = 500;
261
+ this.uploadMaxFileSize = 1024;
251
262
  this.threadItem = false;
252
263
  this.removable = false;
264
+ this.inlineFormat = false;
253
265
  this.slashCommandDiscovered = false;
254
266
  this._title = "";
255
267
  this._bodyJson = null;
@@ -903,13 +915,19 @@ export class JantComposeEditor extends LitElement {
903
915
  }
904
916
  }
905
917
 
906
- // Notify parent dialog of content changes for draft auto-save
907
- for (const key of changed.keys()) {
908
- if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
909
- this.dispatchEvent(
910
- new Event("jant:compose-content-changed", { bubbles: true }),
911
- );
912
- break;
918
+ // Notify parent dialog of content changes for draft auto-save. A format
919
+ // conversion writes content fields too, but it's not a user edit, so skip
920
+ // the notification once when asked.
921
+ if (this._suppressContentChangedOnce) {
922
+ this._suppressContentChangedOnce = false;
923
+ } else {
924
+ for (const key of changed.keys()) {
925
+ if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
926
+ this.dispatchEvent(
927
+ new Event("jant:compose-content-changed", { bubbles: true }),
928
+ );
929
+ break;
930
+ }
913
931
  }
914
932
  }
915
933
  }
@@ -924,6 +942,39 @@ export class JantComposeEditor extends LitElement {
924
942
  };
925
943
  }
926
944
 
945
+ /**
946
+ * Raw field values for format conversion. Unlike {@link getData}, this returns
947
+ * every field regardless of the current format (so a hidden quote/url survives
948
+ * a switch) plus the freshest, normalized body.
949
+ */
950
+ getConvertibleFields(): ComposeConvertFields {
951
+ return {
952
+ title: this._title,
953
+ url: this._url,
954
+ quoteText: this._quoteText,
955
+ quoteAuthor: this._quoteAuthor,
956
+ showTitle: this._showTitle,
957
+ bodyJson: this._normalizeDocJson(
958
+ this._editor?.getJSON() ?? this._bodyJson,
959
+ ),
960
+ };
961
+ }
962
+
963
+ /**
964
+ * Write back fields produced by `convertComposeFormat`. The body is applied via
965
+ * `_bodyJson` only — the imminent format change recreates the editor from it, so
966
+ * calling `setContent` here would be redundant.
967
+ */
968
+ applyConvertedFields(fields: ComposeConvertFields): void {
969
+ this._suppressContentChangedOnce = true;
970
+ this._title = fields.title;
971
+ this._url = fields.url;
972
+ this._quoteText = fields.quoteText;
973
+ this._quoteAuthor = fields.quoteAuthor;
974
+ this._showTitle = fields.showTitle;
975
+ this._bodyJson = fields.bodyJson;
976
+ }
977
+
927
978
  /** Pre-fill all fields for edit mode or draft restore */
928
979
  populate(data: {
929
980
  format: string;
@@ -1736,6 +1787,7 @@ export class JantComposeEditor extends LitElement {
1736
1787
  @input=${(e: Event) => this._onInput("_title", e)}
1737
1788
  @focus=${(e: Event) => this._onFieldFocus(e)}
1738
1789
  @keydown=${(e: globalThis.KeyboardEvent) => {
1790
+ if (e.isComposing || e.keyCode === 229) return;
1739
1791
  if (e.key === "Enter") {
1740
1792
  e.preventDefault();
1741
1793
  this._editor?.commands.focus("start");
@@ -2432,7 +2484,7 @@ export class JantComposeEditor extends LitElement {
2432
2484
  `;
2433
2485
  }
2434
2486
 
2435
- private _renderThreadPostHeader() {
2487
+ private _renderFormatHeader() {
2436
2488
  const formatLabels: Record<ComposeFormat, string> = {
2437
2489
  note: this.labels.note,
2438
2490
  link: this.labels.link,
@@ -2462,7 +2514,7 @@ export class JantComposeEditor extends LitElement {
2462
2514
  @click=${() => {
2463
2515
  if (this.format !== f) {
2464
2516
  this.dispatchEvent(
2465
- new CustomEvent("jant:thread-format-change", {
2517
+ new CustomEvent("jant:format-change", {
2466
2518
  bubbles: true,
2467
2519
  detail: { format: f },
2468
2520
  }),
@@ -2517,7 +2569,9 @@ export class JantComposeEditor extends LitElement {
2517
2569
 
2518
2570
  render() {
2519
2571
  return html`
2520
- ${this.threadItem ? this._renderThreadPostHeader() : nothing}
2572
+ ${this.threadItem || this.inlineFormat
2573
+ ? this._renderFormatHeader()
2574
+ : nothing}
2521
2575
  <section class="compose-body">
2522
2576
  ${this.format === "note"
2523
2577
  ? this._renderNoteFields()
@@ -188,6 +188,8 @@ export class JantComposeFullscreen extends LitElement {
188
188
 
189
189
  private _onDocumentKeydown = (e: globalThis.KeyboardEvent) => {
190
190
  if (!this._open || e.key !== "Escape") return;
191
+ // Let IME consume Escape during composition (e.g. CJK candidate dismiss).
192
+ if (e.isComposing || e.keyCode === 229) return;
191
193
  if (this._hasActiveEscapeOverlay()) return;
192
194
 
193
195
  e.preventDefault();
@@ -273,6 +275,7 @@ export class JantComposeFullscreen extends LitElement {
273
275
  this._title = (e.target as HTMLInputElement).value;
274
276
  }}
275
277
  @keydown=${(e: globalThis.KeyboardEvent) => {
278
+ if (e.isComposing || e.keyCode === 229) return;
276
279
  if (e.key === "Enter") {
277
280
  e.preventDefault();
278
281
  this._editor?.commands.focus("start");
@@ -103,6 +103,10 @@ export class JantNavManager extends LitElement {
103
103
  if (!("key" in event) || event.key !== "Escape" || !this._showPreviewMore) {
104
104
  return;
105
105
  }
106
+ // Defensive: nav editor has many text inputs; let IME swallow Escape
107
+ // when the user is dismissing a CJK candidate popup.
108
+ const ke = event as globalThis.KeyboardEvent;
109
+ if (ke.isComposing || ke.keyCode === 229) return;
106
110
 
107
111
  event.preventDefault();
108
112
  this.#closePreviewMore();
@@ -169,6 +169,9 @@ export class JantPostMenu extends LitElement {
169
169
 
170
170
  #handleKeydown = (e: Event) => {
171
171
  const ke = e as globalThis.KeyboardEvent;
172
+ // Let IME consume Escape during composition (e.g. dismissing the CJK
173
+ // candidate popup in the collection search input).
174
+ if (ke.isComposing || ke.keyCode === 229) return;
172
175
  if (ke.key === "Escape") {
173
176
  if (this._addCollectionPanelOpen) {
174
177
  this.#closeAddCollectionPanel();
@@ -418,6 +418,9 @@ export class JantRepoPicker extends LitElement {
418
418
 
419
419
  #handleEscape = (e: KeyboardEvent) => {
420
420
  if (e.key !== "Escape") return;
421
+ // Let IME consume Escape during composition (e.g. dismissing the CJK
422
+ // candidate popup in the owner/repo search inputs).
423
+ if (e.isComposing || e.keyCode === 229) return;
421
424
  if (this._ownerOpen || this._repoOpen) {
422
425
  this._ownerOpen = false;
423
426
  this._repoOpen = false;
@@ -450,6 +450,7 @@ export class JantSettingsGeneral extends LitElement {
450
450
  };
451
451
 
452
452
  private _onLocalePickerKeydown = (e: KeyboardEvent) => {
453
+ if (e.isComposing || e.keyCode === 229) return;
453
454
  if (e.key === "Escape" && this._localeOpen) {
454
455
  this._localeOpen = false;
455
456
  this._localeQuery = "";
@@ -614,6 +615,8 @@ export class JantSettingsGeneral extends LitElement {
614
615
  dirty: boolean,
615
616
  loading: boolean,
616
617
  ) {
618
+ // Pressing Enter to commit an IME candidate must not also submit the form.
619
+ if (e.isComposing || e.keyCode === 229) return;
617
620
  if (
618
621
  e.key === "Enter" &&
619
622
  !loading &&
@@ -26,6 +26,7 @@ import {
26
26
  queueToastForNextPage,
27
27
  } from "./toast.js";
28
28
  import { openReplyForArticle } from "./compose-launch.js";
29
+ import { hydratePartial } from "./hydrate-partial.js";
29
30
  import { getJsonString, readJsonObject } from "./json.js";
30
31
  import { uploadViaSession } from "./upload-session.js";
31
32
  import { publicPath } from "./runtime-paths.js";
@@ -75,6 +76,10 @@ async function refreshTimelineThreadView(
75
76
  if (!html) return false;
76
77
 
77
78
  content.innerHTML = html;
79
+ // Swapped-in markup carries interactions whose per-element setup only runs
80
+ // on DOMContentLoaded (thread "Show more" toggle, feed video autoplay, audio
81
+ // waveform); re-initialize them or they stay inert until a full reload.
82
+ hydratePartial(content);
78
83
  return true;
79
84
  } catch {
80
85
  return false;
@@ -97,6 +102,7 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
97
102
  );
98
103
  if (!content) return false;
99
104
  content.innerHTML = html;
105
+ hydratePartial(content);
100
106
  return true;
101
107
  }
102
108
 
@@ -106,6 +112,11 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
106
112
  if (!article) return false;
107
113
 
108
114
  article.outerHTML = html;
115
+ // outerHTML detaches `article`; re-query the replacement to hydrate it.
116
+ const nextArticle = document.querySelector<HTMLElement>(
117
+ `article[data-post-id="${postId}"]`,
118
+ );
119
+ if (nextArticle) hydratePartial(nextArticle);
109
120
  return true;
110
121
  } catch {
111
122
  return false;
@@ -125,6 +136,12 @@ async function refreshPostPageView(postId: string): Promise<boolean> {
125
136
  if (!html) return false;
126
137
 
127
138
  container.outerHTML = html;
139
+ // outerHTML detaches `container`; re-query the replacement to hydrate it
140
+ // (see refreshTimelineThreadView).
141
+ const next = document.querySelector<HTMLElement>(
142
+ `[data-post-view][data-post-view-id="${postId}"]`,
143
+ );
144
+ if (next) hydratePartial(next);
128
145
  return true;
129
146
  } catch {
130
147
  return false;
@@ -337,7 +337,7 @@ function handleMuteToggle(event: Event): void {
337
337
  }
338
338
 
339
339
  export function initFeedVideoPlayer(
340
- root: globalThis.ParentNode = document,
340
+ root: globalThis.Document | globalThis.Element = document,
341
341
  ): void {
342
342
  const videos = root.querySelectorAll<HTMLVideoElement>(
343
343
  "[data-feed-short-video]",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Re-initialize interactive behaviors inside a server-rendered fragment that
3
+ * was swapped into the DOM after page load — e.g. compose-bridge replacing a
4
+ * timeline item or post view after a reply or edit.
5
+ *
6
+ * Most interactions survive a swap on their own: note-expand and the audio
7
+ * transport use document-level event delegation, and media-scroll-hint runs its
8
+ * own MutationObserver. The ones gathered here need per-element setup
9
+ * (IntersectionObserver / ResizeObserver / canvas drawing) that otherwise only
10
+ * runs once on DOMContentLoaded, so a freshly swapped fragment would stay inert
11
+ * until a full reload. Each initializer is idempotent, so calling this on a root
12
+ * that already contains initialized nodes is safe.
13
+ */
14
+
15
+ import { setupThreadContexts } from "./thread-context.js";
16
+ import { initFeedVideoPlayer } from "./feed-video-player.js";
17
+ import { initPrecomputedWaveforms } from "./audio-player.js";
18
+
19
+ export function hydratePartial(
20
+ root: globalThis.Document | globalThis.Element,
21
+ ): void {
22
+ setupThreadContexts(root);
23
+ initFeedVideoPlayer(root);
24
+ initPrecomputedWaveforms(root);
25
+ }
@@ -32,6 +32,60 @@ export function getJsonNumber(value: unknown, key: string): number | undefined {
32
32
  export async function readJsonObject(
33
33
  response: Response,
34
34
  ): Promise<Record<string, unknown>> {
35
- const data = await response.json();
36
- return isJsonObject(data) ? data : {};
35
+ const text = await response.text();
36
+ if (!text.trim()) return {};
37
+ try {
38
+ const data = JSON.parse(text);
39
+ return isJsonObject(data) ? data : {};
40
+ } catch {
41
+ throw new Error(
42
+ `Expected JSON (HTTP ${response.status}) but got: ${truncate(text.trim(), 200)}`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read a server error message from a failed Response.
49
+ * Prefers JSON `{ error }`, falls back to the raw text body so server-side
50
+ * failures (e.g. plain-text 404 from an edge/proxy) reach the user instead of
51
+ * being masked by a cryptic JSON parse error.
52
+ */
53
+ export async function readErrorMessage(
54
+ response: Response,
55
+ fallback: string,
56
+ ): Promise<string> {
57
+ let text: string;
58
+ try {
59
+ text = await response.text();
60
+ } catch {
61
+ return fallback;
62
+ }
63
+ return extractErrorMessage(text, fallback);
64
+ }
65
+
66
+ /** Same as readErrorMessage but for a body already read as text (e.g. XHR). */
67
+ export function readErrorMessageFromText(
68
+ text: string,
69
+ fallback: string,
70
+ ): string {
71
+ return extractErrorMessage(text, fallback);
72
+ }
73
+
74
+ function extractErrorMessage(text: string, fallback: string): string {
75
+ const trimmed = text.trim();
76
+ if (!trimmed) return fallback;
77
+ try {
78
+ const data = JSON.parse(trimmed);
79
+ if (isJsonObject(data)) {
80
+ const msg = getJsonString(data, "error");
81
+ if (msg) return msg;
82
+ }
83
+ } catch {
84
+ // Not JSON — fall through and surface the raw text below.
85
+ }
86
+ return truncate(trimmed, 200);
87
+ }
88
+
89
+ function truncate(value: string, max: number): string {
90
+ return value.length > max ? `${value.slice(0, max)}…` : value;
37
91
  }