@jant/core 0.6.15 → 0.6.16

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/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { LitElement, html, nothing } from "lit";
11
11
  import { classMap } from "lit/directives/class-map.js";
12
+ import { repeat } from "lit/directives/repeat.js";
12
13
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
13
14
  import { unsafeSVG } from "lit/directives/unsafe-svg.js";
14
15
  import type { Editor, JSONContent } from "@tiptap/core";
@@ -20,8 +21,10 @@ import type {
20
21
  ComposeSubmitDetail,
21
22
  ComposeSubmitAttachment,
22
23
  ComposeAttachment,
24
+ ComposeRowStatus,
23
25
  DraftItem,
24
26
  LocalDraft,
27
+ LocalDraftMedia,
25
28
  ComposeFullscreenOpenDetail,
26
29
  ComposeFullscreenReplyContext,
27
30
  ComposeFullscreenCloseDetail,
@@ -61,6 +64,18 @@ interface ReplyToData {
61
64
  interface ThreadItem {
62
65
  id: string;
63
66
  format: ComposeFormat;
67
+ /**
68
+ * Per-post publish date. Empty means "follows the thread" — the server fills
69
+ * it from the root (see `createThreadWithAttachments`). The root's own date
70
+ * stays on `_publishedAtInput` so every existing edit/draft/submit path keeps
71
+ * working unchanged.
72
+ */
73
+ publishedAtInput?: string;
74
+ publishedAtTimeMinutes?: number | null;
75
+ /** Per-post permalink. Empty means the server assigns a random id. */
76
+ slug?: string;
77
+ /** Last availability answer for this post's slug. */
78
+ slugTaken?: boolean;
64
79
  }
65
80
 
66
81
  interface ApiMediaAttachment {
@@ -91,6 +106,7 @@ type ApiAttachment = ApiMediaAttachment | ApiTextAttachment;
91
106
  interface ComposePostResponse {
92
107
  id: string;
93
108
  threadId?: string;
109
+ threadPosition?: number;
94
110
  format: ComposeFormat;
95
111
  slug?: string | null;
96
112
  visibility?: ComposeVisibility | null;
@@ -116,11 +132,17 @@ interface ComposeOpenOptions {
116
132
  collectionId?: string;
117
133
  restoreDraft?: boolean;
118
134
  initialFormat?: ComposeFormat;
135
+ /** false suppresses the "Draft restored." toast (failure reopen: the error toast is enough) */
136
+ restoreToast?: boolean;
137
+ /** Uploads the bridge knows completed — merged over the draft's own media snapshot */
138
+ restoreMedia?: LocalDraftMedia[];
119
139
  }
120
140
 
121
141
  interface ComposeReplyOpenOptions {
122
142
  restoreDraft?: boolean;
123
143
  initialFormat?: ComposeFormat;
144
+ restoreToast?: boolean;
145
+ restoreMedia?: LocalDraftMedia[];
124
146
  }
125
147
 
126
148
  interface ComposeStateSnapshot {
@@ -136,7 +158,6 @@ interface ComposeStateSnapshot {
136
158
  quoteText: string;
137
159
  quoteAuthor: string;
138
160
  rating: number;
139
- showTitle: boolean;
140
161
  showRating: boolean;
141
162
  attachments: Array<{
142
163
  clientId: string;
@@ -160,21 +181,20 @@ interface ComposeStateSnapshot {
160
181
 
161
182
  const EDITOR_FLOATING_UI_SELECTOR = "[data-editor-floating-ui]";
162
183
 
184
+ /**
185
+ * Row key for the single-post composer. Thread posts key on their own id; a
186
+ * lone post has no thread item to borrow one from.
187
+ */
188
+ const SINGLE_ROW_ID = "single";
189
+
163
190
  interface ComposeFilePickerCloseDetail {
164
191
  cancelled?: boolean;
165
192
  }
166
193
 
167
- interface ComposePublishSummaryChip {
168
- kind: "publishedAt" | "slug";
169
- text: string;
170
- actionLabel: string;
171
- value: string;
172
- }
173
-
174
194
  const COMPOSE_PUBLISH_PANEL_FULLSCREEN_QUERY =
175
195
  "(max-width: 700px), (max-height: 760px), (hover: none) and (pointer: coarse)";
176
196
 
177
- const COMPOSE_DIALOG_HEADER_ICONS = {
197
+ const COMPOSE_SHEET_ROW_ICONS = {
178
198
  drafts: `
179
199
  <rect x="3.85" y="3.45" width="7.85" height="8.35" rx="2.35" />
180
200
  <rect
@@ -183,7 +203,7 @@ const COMPOSE_DIALOG_HEADER_ICONS = {
183
203
  width="7.85"
184
204
  height="8.35"
185
205
  rx="2.35"
186
- fill="var(--compose-dialog-icon-paper-fill)"
206
+ fill="var(--compose-icon-paper-fill)"
187
207
  stroke="none"
188
208
  />
189
209
  <rect x="6.15" y="5.75" width="7.85" height="8.35" rx="2.35" />
@@ -191,6 +211,14 @@ const COMPOSE_DIALOG_HEADER_ICONS = {
191
211
  <path d="M8.55 10.8h3.95" stroke-width="1.2" />
192
212
  <path d="M8.55 13.05h2.45" stroke-width="1.2" />
193
213
  `,
214
+ /* A bookmark: keep this where you can come back to it. One silhouette holds
215
+ up at the 16px these rows render at, where the page-and-pencil it replaced
216
+ turned to mush — and it stays clearly apart from the stacked pages above,
217
+ which the page alone did not. The down-arrow/tray before that read as a
218
+ download. */
219
+ saveDraft: `
220
+ <path d="M6.65 3.6h4.7a2 2 0 0 1 2 2v8.7L9 11.75l-4.35 2.55V5.6a2 2 0 0 1 2-2Z" />
221
+ `,
194
222
  } as const;
195
223
 
196
224
  const COMPOSE_PUBLISH_VISIBILITY_ICONS: Record<ComposeVisibility, string> = {
@@ -216,15 +244,43 @@ const COMPOSE_PUBLISH_ACTION_ICONS = {
216
244
  check: `
217
245
  <path d="M4.35 8.2 6.9 10.7 11.65 5.95" />
218
246
  `,
219
- chevron: `
220
- <path d="M5.1 6.45 8 9.3l2.9-2.85" />
247
+ caretRight: `
248
+ <path d="M6.45 5.1 9.3 8l-2.85 2.9" />
249
+ `,
250
+ close: `
251
+ <path d="M4.5 4.5 11.5 11.5" />
252
+ <path d="M11.5 4.5 4.5 11.5" />
253
+ `,
254
+ calendar: `
255
+ <rect x="2.75" y="3.45" width="10.5" height="9.8" rx="2.2" />
256
+ <path d="M5.35 2.55v2.1" />
257
+ <path d="M10.65 2.55v2.1" />
258
+ <path d="M2.75 6.2h10.5" />
259
+ `,
260
+ /* Two sliders — "settings for this thing", without borrowing the gear, which
261
+ elsewhere means site settings. No enclosing box: the trigger is already a
262
+ round button, and a rounded square inside a circle read as two competing
263
+ frames. Drawn on a 24 grid so the tracks have room to separate. */
264
+ options: `
265
+ <path d="M4 8.5h3.4" stroke-width="1.8" />
266
+ <path d="M12.6 8.5H20" stroke-width="1.8" />
267
+ <circle cx="10" cy="8.5" r="2.2" stroke-width="1.8" />
268
+ <path d="M4 15.5h7.4" stroke-width="1.8" />
269
+ <path d="M16.6 15.5H20" stroke-width="1.8" />
270
+ <circle cx="14" cy="15.5" r="2.2" stroke-width="1.8" />
221
271
  `,
222
272
  } as const;
223
273
 
224
274
  const COMPOSE_COLLECTION_PICKER_ICONS = {
275
+ /* A stack seen edge-on, not a folder: a collection is a post filed under
276
+ several topics at once, and the folder glyph promises a single container
277
+ the model does not have. Drawn spanning y 3.5–12.5 so the artwork's centre
278
+ is the viewBox's — it used to sit at 6.9 against a centre of 8, and since
279
+ layout centres the svg *box*, that 1.1-unit drift rode straight through as
280
+ an icon floating above its own label. */
225
281
  collection: `
226
- <rect x="3" y="5.05" width="10" height="8.15" rx="2.2" />
227
- <path d="M5.1 5.05V4.2a1.1 1.1 0 0 1 1.1-1.1h3.6a1.1 1.1 0 0 1 1.1 1.1v.85" />
282
+ <path d="M2.5 6.3 8 3.5l5.5 2.8L8 9.1 2.5 6.3Z" />
283
+ <path d="M2.5 9.7 8 12.5l5.5-2.8" />
228
284
  `,
229
285
  search: `
230
286
  <circle cx="7.1" cy="7.1" r="3.65" />
@@ -239,18 +295,15 @@ const COMPOSE_COLLECTION_PICKER_ICONS = {
239
295
  <path d="M8 5.55v4.9" />
240
296
  <path d="M5.55 8h4.9" />
241
297
  `,
242
- chevron: `
243
- <path d="M5.1 6.45 8 9.3l2.9-2.85" />
244
- `,
245
298
  } as const;
246
299
 
247
- function renderComposeHeaderIcon(
248
- icon: (typeof COMPOSE_DIALOG_HEADER_ICONS)[keyof typeof COMPOSE_DIALOG_HEADER_ICONS],
300
+ function renderComposeSheetRowIcon(
301
+ icon: (typeof COMPOSE_SHEET_ROW_ICONS)[keyof typeof COMPOSE_SHEET_ROW_ICONS],
249
302
  ) {
250
303
  return html`<svg
251
- class="compose-dialog-header-icon"
252
- width="24"
253
- height="24"
304
+ class="compose-sheet-row-icon"
305
+ width="18"
306
+ height="18"
254
307
  viewBox="0 0 18 18"
255
308
  fill="none"
256
309
  stroke="currentColor"
@@ -528,6 +581,38 @@ export class JantComposeDialog extends LitElement {
528
581
  }
529
582
  }
530
583
 
584
+ /**
585
+ * Whether a note's title field starts visible, remembered per browser.
586
+ * Titling notes is a habit rather than a per-post decision, so the last
587
+ * deliberate answer carries over. One key, not one per collection: where the
588
+ * note was started says nothing about whether it wants a name.
589
+ */
590
+ private static _NOTE_TITLE_KEY = "jant:compose-note-title";
591
+
592
+ /** Defaults to on, so a browser with nothing stored behaves as it always has. */
593
+ private static _getNoteTitleDefault(): boolean {
594
+ try {
595
+ return (
596
+ globalThis.localStorage.getItem(JantComposeDialog._NOTE_TITLE_KEY) !==
597
+ "0"
598
+ );
599
+ } catch {
600
+ // localStorage unavailable
601
+ return true;
602
+ }
603
+ }
604
+
605
+ private static _setNoteTitleDefault(show: boolean) {
606
+ try {
607
+ globalThis.localStorage.setItem(
608
+ JantComposeDialog._NOTE_TITLE_KEY,
609
+ show ? "1" : "0",
610
+ );
611
+ } catch {
612
+ // localStorage unavailable
613
+ }
614
+ }
615
+
531
616
  static properties = {
532
617
  collections: { type: Array },
533
618
  labels: { type: Object },
@@ -561,13 +646,16 @@ export class JantComposeDialog extends LitElement {
561
646
  _addCollectionPanelOpen: { state: true },
562
647
  _replyToId: { state: true },
563
648
  _replyToData: { state: true },
649
+ _replyParentPosition: { state: true },
564
650
  _replyExpanded: { state: true },
565
651
  _threadItems: { state: true },
652
+ _rowStatus: { state: true },
566
653
  _focusedThreadIndex: { state: true },
567
654
  _slug: { state: true },
568
655
  _publishedAtInput: { state: true },
569
656
  _visibility: { state: true },
570
657
  _showPublishPanel: { state: true },
658
+ _postMetaIndex: { state: true },
571
659
  _publishPanelFullscreen: { state: true },
572
660
  _suggestedSlug: { state: true },
573
661
  _suggestedSlugLoading: { state: true },
@@ -606,13 +694,20 @@ export class JantComposeDialog extends LitElement {
606
694
  declare _addCollectionPanelOpen: boolean;
607
695
  declare _replyToId: string | null;
608
696
  declare _replyToData: ReplyToData | null;
697
+ /** Where the post being replied to sits in its own chain; see
698
+ * `_positionLabel`. Null until the parent has been read back. */
699
+ declare _replyParentPosition: number | null;
609
700
  declare _replyExpanded: boolean;
610
701
  declare _threadItems: ThreadItem[];
702
+ /** Each row's own answer, keyed by row id. See `_rowIds`. */
703
+ declare _rowStatus: ReadonlyMap<string, ComposeRowStatus>;
611
704
  declare _focusedThreadIndex: number;
612
705
  declare _slug: string;
613
706
  declare _publishedAtInput: string;
614
707
  declare _visibility: ComposeVisibility;
615
708
  declare _showPublishPanel: boolean;
709
+ /** Index of the post whose date/permalink panel is open; null when closed. */
710
+ declare _postMetaIndex: number | null;
616
711
  declare _publishPanelFullscreen: boolean;
617
712
  declare _suggestedSlug: string;
618
713
  declare _suggestedSlugLoading: boolean;
@@ -696,8 +791,10 @@ export class JantComposeDialog extends LitElement {
696
791
  this._replyToData = null;
697
792
  this._replyExpanded = false;
698
793
  this._threadItems = [];
794
+ this._rowStatus = new Map();
699
795
  this._focusedThreadIndex = 0;
700
796
  this._replyThreadRootId = null;
797
+ this._replyParentPosition = null;
701
798
  this._replyRefreshKind = null;
702
799
  this._replyRefreshId = null;
703
800
  this._slug = "";
@@ -711,6 +808,7 @@ export class JantComposeDialog extends LitElement {
711
808
  this._sourceCollectionId = null;
712
809
  this._showPublishPanel = false;
713
810
  this._publishPanelFullscreen = false;
811
+ this._postMetaIndex = null;
714
812
  this._suggestedSlug = "";
715
813
  this._suggestedSlugLoading = false;
716
814
  this._slugCheckLoading = false;
@@ -723,11 +821,62 @@ export class JantComposeDialog extends LitElement {
723
821
  return this.querySelector("jant-compose-editor");
724
822
  }
725
823
 
824
+ /**
825
+ * The rows the composer currently has, in order — thread posts by their own
826
+ * id, or the single composer's one row.
827
+ *
828
+ * Everything derived from row content reduces over this list, so a row that
829
+ * has been removed stops being asked the moment `_threadItems` changes. No
830
+ * DOM read is involved, which is the point: a render reads the DOM as it
831
+ * stands *before* that render's own changes are committed.
832
+ */
833
+ private get _rowIds(): string[] {
834
+ return this._threadItems.length > 0
835
+ ? this._threadItems.map((item) => item.id)
836
+ : [SINGLE_ROW_ID];
837
+ }
838
+
839
+ private _handleRowStatus(rowId: string, status: ComposeRowStatus) {
840
+ const previous = this._rowStatus.get(rowId);
841
+ if (
842
+ previous?.hasContent === status.hasContent &&
843
+ previous?.publishable === status.publishable
844
+ ) {
845
+ return;
846
+ }
847
+ const next = new Map(this._rowStatus);
848
+ next.set(rowId, status);
849
+ this._rowStatus = next;
850
+ }
851
+
852
+ protected willUpdate(changed: Map<string, unknown>) {
853
+ super.willUpdate(changed);
854
+
855
+ // An answer belongs to the row that gave it. When the set of rows changes,
856
+ // drop the ones that are gone so a row can never inherit an answer that
857
+ // was never about it — leaving thread mode, in particular, hands the single
858
+ // row's key back to a fresh editor.
859
+ if (changed.has("_threadItems")) {
860
+ const live = new Set(this._rowIds);
861
+ if ([...this._rowStatus.keys()].some((id) => !live.has(id))) {
862
+ this._rowStatus = new Map(
863
+ [...this._rowStatus].filter(([id]) => live.has(id)),
864
+ );
865
+ }
866
+ }
867
+ }
868
+
726
869
  protected updated(changed: Map<string, unknown>) {
727
870
  super.updated(changed);
728
871
  if (this._initialSnapshot === null && this._editor) {
729
872
  this._captureInitialSnapshot();
730
873
  }
874
+ // The visible title used to be the dialog's accessible name. Nothing on
875
+ // screen needs to replace it, but a screen reader announcing an unnamed
876
+ // dialog does — so the name moves to an attribute, where it costs no space.
877
+ if (changed.has("labels") && this._dialogEl && this.labels) {
878
+ this._dialogEl.setAttribute("aria-label", this.labels.composeDialogLabel);
879
+ }
731
880
  if (
732
881
  changed.has("_addCollectionPanelOpen") &&
733
882
  this._addCollectionPanelOpen
@@ -762,6 +911,9 @@ export class JantComposeDialog extends LitElement {
762
911
  if (this._showCollection) {
763
912
  this._updateCollectionPopoverSide();
764
913
  }
914
+ if (this._postMetaIndex !== null) {
915
+ this._updatePostMetaPanelLayout();
916
+ }
765
917
  }
766
918
 
767
919
  reset() {
@@ -789,8 +941,10 @@ export class JantComposeDialog extends LitElement {
789
941
  this._replyToData = null;
790
942
  this._replyExpanded = false;
791
943
  this._threadItems = [];
944
+ this._rowStatus = new Map();
792
945
  this._focusedThreadIndex = 0;
793
946
  this._replyThreadRootId = null;
947
+ this._replyParentPosition = null;
794
948
  this._replyRefreshKind = null;
795
949
  this._replyRefreshId = null;
796
950
  this._slug = "";
@@ -846,7 +1000,7 @@ export class JantComposeDialog extends LitElement {
846
1000
  }
847
1001
  }
848
1002
 
849
- async openEdit(id: string) {
1003
+ async openEdit(id: string, options?: { restoreToast?: boolean }) {
850
1004
  this.reset();
851
1005
  const requestId = ++this._openEditRequestId;
852
1006
  this._openingEdit = true;
@@ -905,7 +1059,10 @@ export class JantComposeDialog extends LitElement {
905
1059
  if (requestId !== this._openEditRequestId) return;
906
1060
 
907
1061
  // Check for a local edit draft (unsaved changes from a previous session)
908
- const restored = this._restoreEditDraftIfAvailable(id);
1062
+ const restored = this._restoreEditDraftIfAvailable(
1063
+ id,
1064
+ options?.restoreToast,
1065
+ );
909
1066
 
910
1067
  if (!restored) {
911
1068
  this._editor?.populate({
@@ -943,11 +1100,30 @@ export class JantComposeDialog extends LitElement {
943
1100
  }
944
1101
  }
945
1102
 
1103
+ async openDraft(id: string) {
1104
+ const dialog = this.closest("dialog");
1105
+ if (dialog && !dialog.open) {
1106
+ dialog.showModal();
1107
+ }
1108
+ await this.updateComplete;
1109
+ this._focusDialogShell();
1110
+ await this._loadDraft(id);
1111
+ }
1112
+
946
1113
  async openNew(options?: ComposeOpenOptions) {
947
1114
  this.reset();
948
1115
 
1116
+ // `reset()` seeds the toggle from `titleByDefault`, which Lit only refreshes
1117
+ // on the next render — after a reply, that value is still the reply's. Say
1118
+ // it outright here. A restored draft overwrites it below with its own
1119
+ // answer, which is right: that one belongs to the draft, not to the habit.
1120
+ this._editor?.setTitleDefault(JantComposeDialog._getNoteTitleDefault());
1121
+
949
1122
  if (options?.restoreDraft !== false) {
950
- await this.restoreLocalDraft();
1123
+ await this.restoreLocalDraft({
1124
+ notify: options?.restoreToast,
1125
+ extraMedia: options?.restoreMedia,
1126
+ });
951
1127
  }
952
1128
 
953
1129
  if (options?.initialFormat) {
@@ -1006,7 +1182,11 @@ export class JantComposeDialog extends LitElement {
1006
1182
  this._format = options?.initialFormat ?? "note";
1007
1183
 
1008
1184
  if (options?.restoreDraft !== false) {
1009
- await this.restoreLocalDraft({ expectedReplyToId: id });
1185
+ await this.restoreLocalDraft({
1186
+ expectedReplyToId: id,
1187
+ notify: options?.restoreToast,
1188
+ extraMedia: options?.restoreMedia,
1189
+ });
1010
1190
  }
1011
1191
  // Collection membership belongs to the existing Thread. Reply compose
1012
1192
  // must not restore or submit stale per-compose Collection state.
@@ -1016,6 +1196,30 @@ export class JantComposeDialog extends LitElement {
1016
1196
  await this.updateComplete;
1017
1197
  this._editor?.focusInput();
1018
1198
  this._captureInitialSnapshot();
1199
+ // Deliberately not awaited: this only feeds the `3/3` marker, and blocking
1200
+ // the composer's open on it would trade a visible delay for a decoration.
1201
+ // The marker stays hidden until the number is known rather than counting
1202
+ // from 1 and then correcting itself.
1203
+ void this._loadReplyParentPosition(id);
1204
+ }
1205
+
1206
+ /**
1207
+ * Read back where the parent sits in its own chain, so the editors below it
1208
+ * can be numbered from there. Failure is silent — a missing marker is a much
1209
+ * smaller problem than a wrong one.
1210
+ */
1211
+ private async _loadReplyParentPosition(replyToId: string) {
1212
+ try {
1213
+ const res = await fetch(`/api/posts/${replyToId}`);
1214
+ if (!res.ok) return;
1215
+ const post = (await res.json()) as ComposePostResponse;
1216
+ // The composer may have been closed, or pointed at another post, while
1217
+ // this was in flight.
1218
+ if (this._replyToId !== replyToId) return;
1219
+ this._replyParentPosition = post.threadPosition ?? null;
1220
+ } catch {
1221
+ // Leave the marker hidden.
1222
+ }
1019
1223
  }
1020
1224
 
1021
1225
  /**
@@ -1030,6 +1234,7 @@ export class JantComposeDialog extends LitElement {
1030
1234
  this._replyThreadRootId = (post.replyToId as string | null)
1031
1235
  ? (post.threadId as string)
1032
1236
  : (post.id as string);
1237
+ this._replyParentPosition = post.threadPosition ?? null;
1033
1238
  const dateText = post.publishedAt
1034
1239
  ? new Date(post.publishedAt * 1000).toLocaleDateString(undefined, {
1035
1240
  month: "short",
@@ -1103,39 +1308,16 @@ export class JantComposeDialog extends LitElement {
1103
1308
  return false;
1104
1309
  }
1105
1310
 
1311
+ /**
1312
+ * Whether any row holds something worth keeping.
1313
+ *
1314
+ * Collection selection alone isn't content — it's metadata that only matters
1315
+ * when paired with actual post content.
1316
+ */
1106
1317
  private _hasContent(): boolean {
1107
- if (this._threadItems.length > 0) {
1108
- // Thread mode: check the first editor for content
1109
- const firstEditor = this.querySelector<JantComposeEditor>(
1110
- "jant-compose-editor",
1111
- );
1112
- if (!firstEditor) return false;
1113
- const data = firstEditor.getData();
1114
- return (
1115
- !!data.body ||
1116
- !!data.title.trim() ||
1117
- !!data.url.trim() ||
1118
- !!data.quoteText.trim() ||
1119
- data.attachments.length > 0
1120
- );
1121
- }
1122
-
1123
- const editor = this._editor;
1124
- if (!editor) return false;
1125
-
1126
- const data = editor.getData();
1127
- if (data.body) return true;
1128
- if (data.title.trim()) return true;
1129
- if (data.url.trim()) return true;
1130
- if (data.quoteText.trim()) return true;
1131
- if (data.quoteAuthor.trim()) return true;
1132
- if (data.attachedTexts.length > 0) return true;
1133
- if (data.rating > 0) return true;
1134
- if (data.attachments.length > 0) return true;
1135
- // Collection selection alone isn't content — it's metadata that
1136
- // only matters when paired with actual post content above.
1137
-
1138
- return false;
1318
+ return this._rowIds.some(
1319
+ (id) => this._rowStatus.get(id)?.hasContent === true,
1320
+ );
1139
1321
  }
1140
1322
 
1141
1323
  private _buildSnapshot(): ComposeStateSnapshot | null {
@@ -1144,10 +1326,8 @@ export class JantComposeDialog extends LitElement {
1144
1326
 
1145
1327
  const editorData = editor.getData();
1146
1328
  const attachedTexts = editor.getEffectiveAttachedTexts();
1147
- const showTitle =
1148
- this._format === "note" && editorData.title.trim().length > 0
1149
- ? editor._showTitle
1150
- : false;
1329
+ // No `showTitle` here: a hidden title reads back as an empty one, so the
1330
+ // toggle is already visible in `title`.
1151
1331
  const showRating = editorData.rating > 0 ? editor._showRating : false;
1152
1332
 
1153
1333
  return {
@@ -1163,7 +1343,6 @@ export class JantComposeDialog extends LitElement {
1163
1343
  quoteText: editorData.quoteText,
1164
1344
  quoteAuthor: editorData.quoteAuthor,
1165
1345
  rating: editorData.rating,
1166
- showTitle,
1167
1346
  showRating,
1168
1347
  attachments: editor._attachments.map((attachment) => ({
1169
1348
  clientId: attachment.clientId,
@@ -1366,8 +1545,10 @@ export class JantComposeDialog extends LitElement {
1366
1545
  quoteText: editorData.quoteText,
1367
1546
  quoteAuthor: editorData.quoteAuthor,
1368
1547
  status,
1369
- slug: isRoot ? this._slug.trim() || undefined : undefined,
1370
- publishedAt: isRoot ? this._getPublishedAtSubmitValue(status) : undefined,
1548
+ slug: this._getPostSlug(index).trim() || undefined,
1549
+ // Every post carries its own date; a reply that left it blank sends
1550
+ // undefined and the server fills it from the root.
1551
+ publishedAt: this._getPostPublishedAtSubmitValue(index, status),
1371
1552
  visibility: isRoot
1372
1553
  ? this._visibilityLocked
1373
1554
  ? undefined
@@ -1377,7 +1558,10 @@ export class JantComposeDialog extends LitElement {
1377
1558
  collectionIds: isRoot && !this._replyToId ? [...this._collectionIds] : [],
1378
1559
  attachments: orderedAttachments,
1379
1560
  replyToId: isRoot ? (this._replyToId ?? undefined) : undefined,
1380
- quietReply: isRoot ? this._quietReply || undefined : undefined,
1561
+ quietReply:
1562
+ isRoot && this._canReplyQuietly()
1563
+ ? this._quietReply || undefined
1564
+ : undefined,
1381
1565
  replyThreadRootId: isRoot
1382
1566
  ? (this._replyThreadRootId ?? undefined)
1383
1567
  : undefined,
@@ -1448,8 +1632,11 @@ export class JantComposeDialog extends LitElement {
1448
1632
  collectionIds: this._replyToId ? [] : [...this._collectionIds],
1449
1633
  attachments: orderedAttachments,
1450
1634
  editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
1635
+ draftSourceId: this._draftSourceId ?? undefined,
1451
1636
  replyToId: this._replyToId ?? undefined,
1452
- quietReply: this._quietReply || undefined,
1637
+ quietReply: this._canReplyQuietly()
1638
+ ? this._quietReply || undefined
1639
+ : undefined,
1453
1640
  replyThreadRootId: this._replyThreadRootId ?? undefined,
1454
1641
  replyRefreshKind: this._replyRefreshKind ?? undefined,
1455
1642
  replyRefreshId: this._replyRefreshId ?? undefined,
@@ -1457,17 +1644,24 @@ export class JantComposeDialog extends LitElement {
1457
1644
  }
1458
1645
 
1459
1646
  private _focusBlockedSubmitField(status: "published" | "draft"): boolean {
1460
- if (
1461
- status === "published" &&
1462
- this._getPublishedAtValidationMessage() !== null
1463
- ) {
1464
- this._revealPublishedAtField();
1465
- return true;
1647
+ if (status === "published") {
1648
+ const posts = Math.max(1, this._threadItems.length);
1649
+ for (let i = 0; i < posts; i++) {
1650
+ if (this._getPostPublishedAtValidationMessage(i) !== null) {
1651
+ this._revealPublishedAtField(i);
1652
+ return true;
1653
+ }
1654
+ }
1466
1655
  }
1467
1656
 
1468
- if (this._getSlugValidationMessage()) {
1469
- this._revealSlugField();
1470
- return true;
1657
+ {
1658
+ const posts = Math.max(1, this._threadItems.length);
1659
+ for (let i = 0; i < posts; i++) {
1660
+ if (this._getPostSlugValidationMessage(i)) {
1661
+ this._revealSlugField(i);
1662
+ return true;
1663
+ }
1664
+ }
1471
1665
  }
1472
1666
 
1473
1667
  // ── Thread mode: validate each editor against its own format ──────
@@ -1566,6 +1760,7 @@ export class JantComposeDialog extends LitElement {
1566
1760
  detail: {
1567
1761
  ...threadPosts[0],
1568
1762
  editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
1763
+ draftSourceId: this._draftSourceId ?? undefined,
1569
1764
  threadPosts,
1570
1765
  pendingAttachments: allPending,
1571
1766
  },
@@ -1975,7 +2170,9 @@ export class JantComposeDialog extends LitElement {
1975
2170
  }
1976
2171
 
1977
2172
  private _scheduleSuggestedSlugRefresh(immediate = false) {
1978
- if (!this._showPublishPanel || this._hasManualSlug()) return;
2173
+ // Only worth fetching while the slug UI is on screen — which is the post's
2174
+ // own panel now, not the publish panel.
2175
+ if (this._postMetaIndex !== 0 || this._hasManualSlug()) return;
1979
2176
  if (!this._canSuggestSlug()) {
1980
2177
  this._slugSuggestRequestId += 1;
1981
2178
  this._suggestedSlug = "";
@@ -2030,7 +2227,7 @@ export class JantComposeDialog extends LitElement {
2030
2227
  if (
2031
2228
  requestId !== this._slugSuggestRequestId ||
2032
2229
  this._hasManualSlug() ||
2033
- !this._showPublishPanel
2230
+ this._postMetaIndex !== 0
2034
2231
  ) {
2035
2232
  return;
2036
2233
  }
@@ -2045,9 +2242,9 @@ export class JantComposeDialog extends LitElement {
2045
2242
  }
2046
2243
  }
2047
2244
 
2048
- private _scheduleSlugAvailabilityCheck() {
2049
- if (!this._hasManualSlug()) {
2050
- this._slugTaken = false;
2245
+ private _scheduleSlugAvailabilityCheck(index = 0) {
2246
+ if (!this._hasPostManualSlug(index)) {
2247
+ this._setPostSlugTaken(index, false);
2051
2248
  this._slugCheckLoading = false;
2052
2249
  return;
2053
2250
  }
@@ -2057,21 +2254,20 @@ export class JantComposeDialog extends LitElement {
2057
2254
  this._slugCheckTimer = null;
2058
2255
  }
2059
2256
 
2060
- const syncError = this._getSlugSyncValidationMessage();
2061
- if (syncError) {
2062
- this._slugTaken = false;
2257
+ if (this._getPostSlugSyncValidationMessage(index)) {
2258
+ this._setPostSlugTaken(index, false);
2063
2259
  this._slugCheckLoading = false;
2064
2260
  return;
2065
2261
  }
2066
2262
 
2067
2263
  this._slugCheckLoading = true;
2068
- const slug = this._slug.trim();
2264
+ const slug = this._getPostSlug(index).trim();
2069
2265
  this._slugCheckTimer = setTimeout(() => {
2070
- void this._checkSlugAvailability(slug);
2266
+ void this._checkSlugAvailability(slug, index);
2071
2267
  }, 250);
2072
2268
  }
2073
2269
 
2074
- private async _checkSlugAvailability(slug: string) {
2270
+ private async _checkSlugAvailability(slug: string, index = 0) {
2075
2271
  this._slugCheckTimer = null;
2076
2272
  const requestId = ++this._slugCheckRequestId;
2077
2273
 
@@ -2088,11 +2284,11 @@ export class JantComposeDialog extends LitElement {
2088
2284
  const json = (await res.json()) as { available?: boolean };
2089
2285
  if (
2090
2286
  requestId !== this._slugCheckRequestId ||
2091
- this._slug.trim() !== slug
2287
+ this._getPostSlug(index).trim() !== slug
2092
2288
  ) {
2093
2289
  return;
2094
2290
  }
2095
- this._slugTaken = json.available === false;
2291
+ this._setPostSlugTaken(index, json.available === false);
2096
2292
  } catch {
2097
2293
  // Server-side create/update remains the final authority.
2098
2294
  } finally {
@@ -2114,9 +2310,9 @@ export class JantComposeDialog extends LitElement {
2114
2310
  });
2115
2311
  }
2116
2312
 
2117
- private _resetCustomSlug() {
2118
- this._slug = "";
2119
- this._slugTaken = false;
2313
+ private _resetCustomSlug(index = 0) {
2314
+ this._setPostSlug(index, "");
2315
+ this._setPostSlugTaken(index, false);
2120
2316
  this._slugCheckLoading = false;
2121
2317
  this._scheduleSuggestedSlugRefresh(true);
2122
2318
  this.updateComplete.then(() => {
@@ -2140,6 +2336,10 @@ export class JantComposeDialog extends LitElement {
2140
2336
  "jant:compose-content-changed",
2141
2337
  this._onContentChanged,
2142
2338
  );
2339
+ this.addEventListener(
2340
+ "jant:title-toggle",
2341
+ this._handleTitleToggle as EventListener,
2342
+ );
2143
2343
  this.addEventListener("jant:file-picker-open", this._handleFilePickerOpen);
2144
2344
  this.addEventListener(
2145
2345
  "jant:file-picker-close",
@@ -2162,6 +2362,10 @@ export class JantComposeDialog extends LitElement {
2162
2362
  window.addEventListener("scroll", this._handleViewportChange, {
2163
2363
  passive: true,
2164
2364
  });
2365
+ document.addEventListener("scroll", this._handleAnyScroll, {
2366
+ capture: true,
2367
+ passive: true,
2368
+ });
2165
2369
  globalThis.visualViewport?.addEventListener(
2166
2370
  "resize",
2167
2371
  this._handleViewportChange,
@@ -2197,6 +2401,10 @@ export class JantComposeDialog extends LitElement {
2197
2401
  "jant:compose-content-changed",
2198
2402
  this._onContentChanged,
2199
2403
  );
2404
+ this.removeEventListener(
2405
+ "jant:title-toggle",
2406
+ this._handleTitleToggle as EventListener,
2407
+ );
2200
2408
  this.removeEventListener(
2201
2409
  "jant:file-picker-open",
2202
2410
  this._handleFilePickerOpen,
@@ -2217,6 +2425,9 @@ export class JantComposeDialog extends LitElement {
2217
2425
  window.removeEventListener("beforeunload", this._onBeforeUnload);
2218
2426
  window.removeEventListener("resize", this._handleViewportChange);
2219
2427
  window.removeEventListener("scroll", this._handleViewportChange);
2428
+ document.removeEventListener("scroll", this._handleAnyScroll, {
2429
+ capture: true,
2430
+ });
2220
2431
  globalThis.visualViewport?.removeEventListener(
2221
2432
  "resize",
2222
2433
  this._handleViewportChange,
@@ -2273,6 +2484,20 @@ export class JantComposeDialog extends LitElement {
2273
2484
  if (this._showCollection) {
2274
2485
  this.updateComplete.then(() => this._updateCollectionPopoverSide());
2275
2486
  }
2487
+ if (this._postMetaIndex !== null) {
2488
+ this.updateComplete.then(() => this._updatePostMetaPanelLayout());
2489
+ }
2490
+ };
2491
+
2492
+ /**
2493
+ * The date/permalink panel is pinned to a pill inside `.compose-scroll`, and
2494
+ * a `scroll` event on that box never reaches `window` — it doesn't bubble.
2495
+ * Capture phase catches it wherever it fires, so the panel travels with its
2496
+ * pill instead of hanging in place once the composer moves under it.
2497
+ */
2498
+ private _handleAnyScroll = () => {
2499
+ if (this._postMetaIndex === null) return;
2500
+ this._updatePostMetaPanelLayout();
2276
2501
  };
2277
2502
 
2278
2503
  private _handleDialogCancel = (e: Event) => {
@@ -2397,6 +2622,12 @@ export class JantComposeDialog extends LitElement {
2397
2622
  return true;
2398
2623
  }
2399
2624
 
2625
+ if (this._postMetaIndex !== null) {
2626
+ // `_closePostMeta` hands focus back to the post itself.
2627
+ this._closePostMeta();
2628
+ return true;
2629
+ }
2630
+
2400
2631
  if (this._altPanelOpen) {
2401
2632
  this._closeAltPanel();
2402
2633
  this._restorePageEditorFocus();
@@ -2928,9 +3159,16 @@ export class JantComposeDialog extends LitElement {
2928
3159
  }
2929
3160
 
2930
3161
  private _getDraftPreview(draft: DraftItem): string | null {
2931
- if (draft.bodyText) return draft.bodyText;
3162
+ if (draft.format === "quote") {
3163
+ if (draft.quoteText) return draft.quoteText;
3164
+ if (draft.title) return draft.title;
3165
+ if (draft.bodyText) return draft.bodyText;
3166
+ if (draft.url) return draft.url;
3167
+ return null;
3168
+ }
3169
+
2932
3170
  if (draft.title) return draft.title;
2933
- if (draft.quoteText) return draft.quoteText;
3171
+ if (draft.bodyText) return draft.bodyText;
2934
3172
  if (draft.url) return draft.url;
2935
3173
  return null;
2936
3174
  }
@@ -2966,6 +3204,18 @@ export class JantComposeDialog extends LitElement {
2966
3204
  }
2967
3205
  };
2968
3206
 
3207
+ /**
3208
+ * Remember the note title toggle for the next new note. Same guard as
3209
+ * `_setVisibility`: an edit, a loaded draft, or a reply answers for that one
3210
+ * post, not for how this author writes. Thread rows are excluded too — the
3211
+ * toggle there answers for a position in a thread.
3212
+ */
3213
+ private _handleTitleToggle = (e: CustomEvent<{ showTitle: boolean }>) => {
3214
+ if (this._editPostId || this._draftSourceId || this._replyToId) return;
3215
+ if ((e.target as JantComposeEditor | null)?.threadItem) return;
3216
+ JantComposeDialog._setNoteTitleDefault(e.detail.showTitle);
3217
+ };
3218
+
2969
3219
  private _cancelDraftSaveTimer() {
2970
3220
  if (this._draftSaveTimer !== null) {
2971
3221
  clearTimeout(this._draftSaveTimer);
@@ -3028,6 +3278,9 @@ export class JantComposeDialog extends LitElement {
3028
3278
  const data = editor.getData();
3029
3279
  return {
3030
3280
  format: item.format,
3281
+ publishedAtInput: item.publishedAtInput,
3282
+ publishedAtTimeMinutes: item.publishedAtTimeMinutes,
3283
+ slug: item.slug,
3031
3284
  title: data.title,
3032
3285
  bodyJson: editor.getNormalizedBodyJson(),
3033
3286
  url: data.url,
@@ -3040,6 +3293,9 @@ export class JantComposeDialog extends LitElement {
3040
3293
  summary: t.summary,
3041
3294
  })),
3042
3295
  attachmentOrder: [...data.attachmentOrder],
3296
+ mediaAttachments: JantComposeDialog._mediaSnapshotFromAttachments(
3297
+ data.attachments,
3298
+ ),
3043
3299
  };
3044
3300
  })
3045
3301
  .filter((item): item is NonNullable<typeof item> => item !== null);
@@ -3097,6 +3353,7 @@ export class JantComposeDialog extends LitElement {
3097
3353
  !!data.quoteText.trim() ||
3098
3354
  !!data.quoteAuthor.trim() ||
3099
3355
  data.rating > 0 ||
3356
+ data.attachments.length > 0 ||
3100
3357
  data.attachedTexts.length > 0;
3101
3358
 
3102
3359
  if (!hasContent) {
@@ -3116,10 +3373,10 @@ export class JantComposeDialog extends LitElement {
3116
3373
  publishedAtTimeMinutes: this._publishedAtTimeMinutes,
3117
3374
  visibility: this._visibility,
3118
3375
  rating: data.rating,
3119
- showTitle:
3120
- this._format === "note" && data.title.trim().length > 0
3121
- ? editor._showTitle
3122
- : false,
3376
+ // The toggle as it stands, not "does a title exist" — restoring a draft
3377
+ // should put the editor back the way it was left, including an open but
3378
+ // still-empty title field.
3379
+ showTitle: this._format === "note" ? editor._showTitle : false,
3123
3380
  showRating: data.rating > 0 ? editor._showRating : false,
3124
3381
  collectionIds: [...this._collectionIds],
3125
3382
  replyToId: this._replyToId,
@@ -3130,6 +3387,9 @@ export class JantComposeDialog extends LitElement {
3130
3387
  summary: t.summary,
3131
3388
  })),
3132
3389
  attachmentOrder: [...data.attachmentOrder],
3390
+ mediaAttachments: JantComposeDialog._mediaSnapshotFromAttachments(
3391
+ data.attachments,
3392
+ ),
3133
3393
  savedAt: Date.now(),
3134
3394
  };
3135
3395
 
@@ -3158,7 +3418,67 @@ export class JantComposeDialog extends LitElement {
3158
3418
  );
3159
3419
  }
3160
3420
 
3161
- async restoreLocalDraft(options?: { expectedReplyToId?: string }) {
3421
+ /**
3422
+ * Snapshot attachments whose upload completed. Only these can be restored
3423
+ * later — for the rest the bytes exist nowhere but in-memory.
3424
+ */
3425
+ private static _mediaSnapshotFromAttachments(
3426
+ attachments: ComposeAttachment[],
3427
+ ): LocalDraftMedia[] {
3428
+ return attachments.flatMap((a) =>
3429
+ a.status === "done" && a.mediaId && a.remoteUrl
3430
+ ? [
3431
+ {
3432
+ clientId: a.clientId,
3433
+ mediaId: a.mediaId,
3434
+ url: a.remoteUrl,
3435
+ mimeType: a.file.type,
3436
+ name: a.file.name || undefined,
3437
+ alt: a.alt || undefined,
3438
+ summary: a.summary,
3439
+ chars: a.chars,
3440
+ },
3441
+ ]
3442
+ : [],
3443
+ );
3444
+ }
3445
+
3446
+ /** Convert stored media snapshots into `populate()` media entries. */
3447
+ private static _restoredMediaToPopulate(media: LocalDraftMedia[]) {
3448
+ return media.map((m) => ({
3449
+ id: m.mediaId,
3450
+ clientId: m.clientId,
3451
+ previewUrl: m.url,
3452
+ mimeType: m.mimeType,
3453
+ originalName: m.name,
3454
+ alt: m.alt,
3455
+ summary: m.summary ?? undefined,
3456
+ chars: m.chars ?? undefined,
3457
+ }));
3458
+ }
3459
+
3460
+ /**
3461
+ * Merge a draft's media snapshot with bridge-supplied completed uploads.
3462
+ * Bridge entries win: they include uploads that finished after the dialog
3463
+ * closed, which the autosaved snapshot predates.
3464
+ */
3465
+ private static _mergeRestoredMedia(
3466
+ snapshot: LocalDraftMedia[] | undefined,
3467
+ extra: LocalDraftMedia[] | undefined,
3468
+ ): Map<string, LocalDraftMedia> {
3469
+ const merged = new Map<string, LocalDraftMedia>();
3470
+ for (const m of snapshot ?? []) merged.set(m.clientId, m);
3471
+ for (const m of extra ?? []) merged.set(m.clientId, m);
3472
+ return merged;
3473
+ }
3474
+
3475
+ async restoreLocalDraft(options?: {
3476
+ expectedReplyToId?: string;
3477
+ /** false suppresses the "Draft restored." toast */
3478
+ notify?: boolean;
3479
+ /** Completed uploads known to the bridge at failure time */
3480
+ extraMedia?: LocalDraftMedia[];
3481
+ }) {
3162
3482
  // Don't restore if already in edit or draft-load mode
3163
3483
  if (this._editPostId || this._draftSourceId) return;
3164
3484
  // Don't restore if the editor already has content (e.g. reopened dialog)
@@ -3219,6 +3539,9 @@ export class JantComposeDialog extends LitElement {
3219
3539
  this._threadItems = draft.threadItems.map((item) => ({
3220
3540
  id: randomUUID(),
3221
3541
  format: item.format,
3542
+ publishedAtInput: item.publishedAtInput,
3543
+ publishedAtTimeMinutes: item.publishedAtTimeMinutes,
3544
+ slug: item.slug,
3222
3545
  }));
3223
3546
  this._focusedThreadIndex = 0;
3224
3547
 
@@ -3245,6 +3568,18 @@ export class JantComposeDialog extends LitElement {
3245
3568
  ];
3246
3569
  });
3247
3570
 
3571
+ // Bridge entries are a flat list — this item owns the clientIds in
3572
+ // its own attachmentOrder.
3573
+ const itemExtraMedia = options?.extraMedia?.filter((m) =>
3574
+ item.attachmentOrder?.includes(m.clientId),
3575
+ );
3576
+ const media = [
3577
+ ...JantComposeDialog._mergeRestoredMedia(
3578
+ item.mediaAttachments,
3579
+ itemExtraMedia,
3580
+ ).values(),
3581
+ ];
3582
+
3248
3583
  editor.populate({
3249
3584
  format: item.format,
3250
3585
  title: item.title || undefined,
@@ -3252,6 +3587,9 @@ export class JantComposeDialog extends LitElement {
3252
3587
  url: item.url || undefined,
3253
3588
  quoteText: item.quoteText || undefined,
3254
3589
  quoteAuthor: item.quoteAuthor || undefined,
3590
+ media: media.length
3591
+ ? JantComposeDialog._restoredMediaToPopulate(media)
3592
+ : undefined,
3255
3593
  textAttachments: textAttachments?.length
3256
3594
  ? textAttachments
3257
3595
  : undefined,
@@ -3260,7 +3598,7 @@ export class JantComposeDialog extends LitElement {
3260
3598
  }
3261
3599
 
3262
3600
  this._draftRestored = true;
3263
- showToast(this.labels.draftRestored);
3601
+ if (options?.notify !== false) showToast(this.labels.draftRestored);
3264
3602
  globalThis.requestAnimationFrame(() => {
3265
3603
  this._captureInitialSnapshot();
3266
3604
  });
@@ -3285,6 +3623,13 @@ export class JantComposeDialog extends LitElement {
3285
3623
  ];
3286
3624
  });
3287
3625
 
3626
+ const media = [
3627
+ ...JantComposeDialog._mergeRestoredMedia(
3628
+ draft.mediaAttachments,
3629
+ options?.extraMedia,
3630
+ ).values(),
3631
+ ];
3632
+
3288
3633
  this._editor?.populate({
3289
3634
  format: draft.format,
3290
3635
  title: draft.title || undefined,
@@ -3295,12 +3640,15 @@ export class JantComposeDialog extends LitElement {
3295
3640
  rating: draft.rating || undefined,
3296
3641
  showTitle: draft.showTitle,
3297
3642
  showRating: draft.showRating,
3643
+ media: media.length
3644
+ ? JantComposeDialog._restoredMediaToPopulate(media)
3645
+ : undefined,
3298
3646
  textAttachments: textAttachments?.length ? textAttachments : undefined,
3299
3647
  attachmentOrder: draft.attachmentOrder,
3300
3648
  });
3301
3649
 
3302
3650
  this._draftRestored = true;
3303
- showToast(this.labels.draftRestored);
3651
+ if (options?.notify !== false) showToast(this.labels.draftRestored);
3304
3652
  globalThis.requestAnimationFrame(() => {
3305
3653
  this._captureInitialSnapshot();
3306
3654
  });
@@ -3310,7 +3658,10 @@ export class JantComposeDialog extends LitElement {
3310
3658
  * Check for a local edit draft for the given post ID and restore it if valid.
3311
3659
  * Returns true if a draft was restored, false otherwise.
3312
3660
  */
3313
- private _restoreEditDraftIfAvailable(postId: string): boolean {
3661
+ private _restoreEditDraftIfAvailable(
3662
+ postId: string,
3663
+ notify?: boolean,
3664
+ ): boolean {
3314
3665
  const key = JantComposeDialog._EDIT_DRAFT_KEY_PREFIX + postId;
3315
3666
 
3316
3667
  let raw: string | null;
@@ -3373,7 +3724,7 @@ export class JantComposeDialog extends LitElement {
3373
3724
  });
3374
3725
 
3375
3726
  this._draftRestored = true;
3376
- showToast(this.labels.draftRestored);
3727
+ if (notify !== false) showToast(this.labels.draftRestored);
3377
3728
  return true;
3378
3729
  }
3379
3730
 
@@ -3455,11 +3806,12 @@ export class JantComposeDialog extends LitElement {
3455
3806
 
3456
3807
  private _renderDraftItem(draft: DraftItem) {
3457
3808
  const preview = this._getDraftPreview(draft);
3809
+ const formatLabel = this.labels[draft.format];
3458
3810
  const menuId = `draft-actions-${draft.id}`;
3459
3811
  const menuOpen = this._draftMenuOpenId === draft.id;
3460
3812
 
3461
3813
  return html`
3462
- <div class="compose-draft-item" @click=${() => this._loadDraft(draft.id)}>
3814
+ <div class="compose-draft-item" @click=${() => this.openDraft(draft.id)}>
3463
3815
  <div class="compose-draft-content">
3464
3816
  ${preview
3465
3817
  ? html`<div class="compose-draft-preview">${preview}</div>`
@@ -3469,6 +3821,8 @@ export class JantComposeDialog extends LitElement {
3469
3821
  Empty draft
3470
3822
  </div>`}
3471
3823
  <div class="compose-draft-meta">
3824
+ <span class="compose-draft-format">${formatLabel}</span>
3825
+ <span aria-hidden="true">·</span>
3472
3826
  ${this._formatDraftDate(draft.updatedAt)}
3473
3827
  </div>
3474
3828
  </div>
@@ -3649,12 +4003,6 @@ export class JantComposeDialog extends LitElement {
3649
4003
  // already pending from loading the post.
3650
4004
  this._cancelDraftSaveTimer();
3651
4005
  this._format = target;
3652
- // Sync the editor's format this tick. Lit commits the `.format` binding
3653
- // only after this render returns, so `_canPublish()` (which reads
3654
- // `editor.getData()`, keyed on the editor's format) would otherwise compute
3655
- // against the stale format for one render and leave the submit button
3656
- // wrongly disabled.
3657
- if (editor) editor.format = target;
3658
4006
  this._showPublishPanel = false;
3659
4007
  if (this._shouldAutofocusFormatInput()) {
3660
4008
  globalThis.requestAnimationFrame(() => this._editor?.focusInput());
@@ -3663,82 +4011,40 @@ export class JantComposeDialog extends LitElement {
3663
4011
 
3664
4012
  // ── Render helpers ────────────────────────────────────────────────
3665
4013
 
3666
- private _renderHeader() {
3667
- const formats = JantComposeDialog._FORMATS;
3668
- const formatLabels: Record<ComposeFormat, string> = {
3669
- note: this.labels.note,
3670
- link: this.labels.link,
3671
- quote: this.labels.quote,
3672
- };
3673
- const draftButtonLabel = this._hasContent()
3674
- ? this.labels.saveAsDraft
3675
- : this.labels.drafts;
3676
- // Format selector sits inline (above each post) whenever more than one post
3677
- // is on screen — a reply (parent shown above) or a multi-post thread. The
3678
- // header then shows a plain title instead of the format selector.
3679
- const isReply = !!(this._replyToId && this._replyToData);
3680
- const showTitle = isReply || this._threadItems.length > 0;
3681
- const headerTitle = this._editPostId
3682
- ? this.labels.editTitle
3683
- : isReply
3684
- ? this.labels.replyTitle
3685
- : this.labels.newThread;
4014
+ /**
4015
+ * The way out, sitting at the end of the post header row where a thread post
4016
+ * keeps its own ×. Compose has no title bar to hang a Cancel off any more:
4017
+ * "New post" restated what an empty composer already says, and Publish
4018
+ * already reads "Update" when editing, so the row was carrying nothing but
4019
+ * two controls that belong closer to the work.
4020
+ *
4021
+ * A thread's posts each own that slot for removing themselves, so there the
4022
+ * exit lives in the options panel instead — see `_renderCloseComposeRow`.
4023
+ */
4024
+ private _renderCloseComposeControl() {
4025
+ if (this.pageMode || this._threadItems.length > 0) return nothing;
3686
4026
 
3687
4027
  return html`
3688
- <header class="compose-dialog-header">
3689
- <button
3690
- type="button"
3691
- class="compose-dialog-cancel"
3692
- @click=${() => this.requestClose()}
4028
+ <button
4029
+ type="button"
4030
+ class="compose-close-btn"
4031
+ aria-label=${this.labels.cancel}
4032
+ title=${this.labels.cancel}
4033
+ @click=${() => this.requestClose()}
4034
+ >
4035
+ <svg
4036
+ width="14"
4037
+ height="14"
4038
+ viewBox="0 0 16 16"
4039
+ fill="none"
4040
+ stroke="currentColor"
4041
+ stroke-width="1.8"
4042
+ stroke-linecap="round"
4043
+ aria-hidden="true"
3693
4044
  >
3694
- ${this.labels.cancel}
3695
- </button>
3696
-
3697
- <div class="compose-dialog-header-center">
3698
- ${showTitle
3699
- ? html`<span class="compose-dialog-title">${headerTitle}</span>`
3700
- : html`
3701
- <div class="compose-segmented">
3702
- <div
3703
- class=${classMap({
3704
- "compose-format-pill": true,
3705
- "compose-format-pill-link": this._format === "link",
3706
- "compose-format-pill-quote": this._format === "quote",
3707
- })}
3708
- ></div>
3709
- ${formats.map(
3710
- (f) => html`
3711
- <button
3712
- type="button"
3713
- class=${classMap({
3714
- "compose-segmented-item": true,
3715
- "compose-segmented-item-active": this._format === f,
3716
- })}
3717
- @click=${() => this._switchFormat(f)}
3718
- >
3719
- ${formatLabels[f]}
3720
- </button>
3721
- `,
3722
- )}
3723
- </div>
3724
- `}
3725
- </div>
3726
-
3727
- <div class="compose-dialog-header-actions">
3728
- ${this._editPostId
3729
- ? nothing
3730
- : html`<button
3731
- type="button"
3732
- class="compose-dialog-header-btn compose-dialog-draft-btn"
3733
- aria-label=${draftButtonLabel}
3734
- title=${draftButtonLabel}
3735
- ?disabled=${this._loading}
3736
- @click=${() => this._handleDraftButtonClick()}
3737
- >
3738
- ${renderComposeHeaderIcon(COMPOSE_DIALOG_HEADER_ICONS.drafts)}
3739
- </button>`}
3740
- </div>
3741
- </header>
4045
+ <path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" />
4046
+ </svg>
4047
+ </button>
3742
4048
  `;
3743
4049
  }
3744
4050
 
@@ -3795,17 +4101,13 @@ export class JantComposeDialog extends LitElement {
3795
4101
  }
3796
4102
  }}
3797
4103
  >
3798
- <span class="compose-collection-trigger-icon">
3799
- ${renderComposeCollectionPickerIcon(
3800
- COMPOSE_COLLECTION_PICKER_ICONS.collection,
3801
- "compose-collection-trigger-svg",
3802
- )}
3803
- </span>
3804
- <span class="compose-collection-label">${selectedLabel}</span>
4104
+ <!-- No chevron: the glyph already says "picker", and a second one
4105
+ pointing down only made the control wider. -->
3805
4106
  ${renderComposeCollectionPickerIcon(
3806
- COMPOSE_COLLECTION_PICKER_ICONS.chevron,
3807
- "compose-collection-chevron",
4107
+ COMPOSE_COLLECTION_PICKER_ICONS.collection,
4108
+ "compose-collection-trigger-svg",
3808
4109
  )}
4110
+ <span class="compose-collection-label">${selectedLabel}</span>
3809
4111
  </button>
3810
4112
  <div
3811
4113
  class="compose-collection-popover"
@@ -4375,56 +4677,24 @@ export class JantComposeDialog extends LitElement {
4375
4677
  };
4376
4678
  }
4377
4679
 
4378
- private _getSlugSummary(): ComposePublishSummaryChip | null {
4379
- const currentSlug = this._slug.trim();
4380
-
4381
- if (currentSlug && this._getSlugValidationMessage() !== null) {
4382
- return null;
4383
- }
4384
-
4385
- if (this._editPostId || this._draftSourceId) {
4386
- if (currentSlug === this._initialSlug) {
4387
- return null;
4388
- }
4389
-
4390
- if (!currentSlug) {
4391
- return {
4392
- kind: "slug",
4393
- text: this.labels.publishSlugSummaryAuto,
4394
- actionLabel: this.labels.publishSlugSummaryAction,
4395
- value: currentSlug,
4396
- };
4397
- }
4398
- }
4399
-
4400
- if (!currentSlug) return null;
4401
-
4402
- return {
4403
- kind: "slug",
4404
- text: `/${currentSlug}`,
4405
- actionLabel: this.labels.publishSlugSummaryAction,
4406
- value: currentSlug,
4407
- };
4408
- }
4409
-
4410
- private _getPublishSummaryChips(): ComposePublishSummaryChip[] {
4411
- const chips: ComposePublishSummaryChip[] = [];
4412
- const publishedAtSummary = this._getPublishedAtSummary();
4413
- if (publishedAtSummary !== null) {
4414
- chips.push({
4415
- kind: "publishedAt",
4416
- text: publishedAtSummary.text,
4417
- actionLabel: this.labels.publishDateSummaryAction,
4418
- value: publishedAtSummary.input,
4419
- });
4420
- }
4680
+ private _getPostPublishedAtSubmitValue(
4681
+ index: number,
4682
+ status: "published" | "draft",
4683
+ ): number | undefined {
4684
+ if (index === 0) return this._getPublishedAtSubmitValue(status);
4685
+ if (status === "draft") return undefined;
4421
4686
 
4422
- const slugSummary = this._getSlugSummary();
4423
- if (slugSummary !== null) {
4424
- chips.push(slugSummary);
4425
- }
4687
+ const item = this._threadItems[index];
4688
+ const input = item?.publishedAtInput ?? "";
4689
+ if (!input.trim()) return undefined;
4426
4690
 
4427
- return chips;
4691
+ const timestamp = buildTimestampFromLocalDate(
4692
+ input,
4693
+ item?.publishedAtTimeMinutes ??
4694
+ getTimestampTimeMinutes(this._getCurrentTimestamp()),
4695
+ );
4696
+ if (timestamp === null) return undefined;
4697
+ return Math.min(timestamp, this._getCurrentTimestamp());
4428
4698
  }
4429
4699
 
4430
4700
  private _getPublishedAtSubmitValue(
@@ -4460,66 +4730,33 @@ export class JantComposeDialog extends LitElement {
4460
4730
  return undefined;
4461
4731
  }
4462
4732
 
4463
- private _openPublishPanelAndFocus(selector: string) {
4733
+ private _openPublishPanelAndFocus(selector: string, index = 0) {
4464
4734
  this._showCollection = false;
4465
4735
  this._collectionSearch = "";
4466
- this._showPublishPanel = true;
4736
+ this._showPublishPanel = false;
4737
+ this._postMetaIndex = index;
4467
4738
  this._confirmPanelOpen = false;
4468
4739
  this._scheduleSuggestedSlugRefresh(true);
4469
- this.updateComplete.then(() => {
4470
- this.querySelector<HTMLElement>(selector)?.focus();
4471
- });
4740
+ this._placeAndFocusPostMetaPanel(selector);
4472
4741
  }
4473
4742
 
4474
- private _revealSlugField() {
4475
- this._openPublishPanelAndFocus(".compose-publish-slug-input");
4743
+ private _revealSlugField(index = 0) {
4744
+ this._openPublishPanelAndFocus(".compose-publish-slug-input", index);
4476
4745
  }
4477
4746
 
4478
- private _revealPublishedAtField() {
4479
- this._openPublishPanelAndFocus(".compose-publish-date-input");
4747
+ private _revealPublishedAtField(index = 0) {
4748
+ this._openPublishPanelAndFocus(".compose-publish-date-input", index);
4480
4749
  }
4481
4750
 
4751
+ /** Every row ready, and every row's own date and permalink accepted. */
4482
4752
  private _canPublish(): boolean {
4483
4753
  if (this._loading) return false;
4484
- if (this._getPublishedAtValidationMessage()) return false;
4485
- if (this._getSlugValidationMessage()) return false;
4486
-
4487
- if (this._threadItems.length > 0) {
4488
- // Thread mode: validate all editors
4489
- const editors = Array.from(
4490
- this.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
4491
- );
4492
- if (editors.length === 0) return false;
4493
- for (const editor of editors) {
4494
- if (editor.getUrlValidationMessage()) return false;
4495
- if (editor.getLinkTitleValidationMessage()) return false;
4496
- }
4497
- // Every editor in the thread must have content
4498
- return editors.every((editor) => {
4499
- const data = editor.getData();
4500
- return (
4501
- !!data.body ||
4502
- !!data.title.trim() ||
4503
- !!data.url.trim() ||
4504
- !!data.quoteText.trim() ||
4505
- data.attachments.length > 0
4506
- );
4507
- });
4508
- }
4509
-
4510
- const editor = this._editor;
4511
- if (!editor) return false;
4512
- if (editor.getUrlValidationMessage()) return false;
4513
- if (editor.getLinkTitleValidationMessage()) return false;
4514
-
4515
- const data = editor.getData();
4516
- if (this._format === "link") {
4517
- return data.url.trim().length > 0;
4518
- }
4519
- if (this._format === "quote") {
4520
- return data.quoteText.trim().length > 0;
4754
+ const rowIds = this._rowIds;
4755
+ for (let i = 0; i < rowIds.length; i++) {
4756
+ if (this._getPostPublishedAtValidationMessage(i)) return false;
4757
+ if (this._getPostSlugValidationMessage(i)) return false;
4521
4758
  }
4522
- return this._hasContent();
4759
+ return rowIds.every((id) => this._rowStatus.get(id)?.publishable === true);
4523
4760
  }
4524
4761
 
4525
4762
  private _focusPublishPanelInitialField() {
@@ -4551,6 +4788,9 @@ export class JantComposeDialog extends LitElement {
4551
4788
  private _setVisibility(visibility: ComposeVisibility) {
4552
4789
  if (this._visibilityLocked) return;
4553
4790
  this._visibility = visibility;
4791
+ // Choosing is the whole job of the panel, so dismiss it. Nothing here
4792
+ // needs confirming — a radio selection is not a form.
4793
+ if (this._showPublishPanel) this._closePublishPanel(true);
4554
4794
  if (!this._editPostId && !this._draftSourceId && !this._replyToId) {
4555
4795
  JantComposeDialog._lastNewPostVisibility = visibility;
4556
4796
  if (this._sourceCollectionId) {
@@ -4562,26 +4802,28 @@ export class JantComposeDialog extends LitElement {
4562
4802
  }
4563
4803
  }
4564
4804
 
4565
- private _onSlugInput(e: Event) {
4805
+ private _onSlugInput(e: Event, index = 0) {
4566
4806
  const value = (e.target as HTMLInputElement).value;
4567
- this._slug = value.toLowerCase();
4568
- this._slugTaken = false;
4807
+ this._setPostSlug(index, value.toLowerCase());
4808
+ this._setPostSlugTaken(index, false);
4569
4809
  this._slugCheckLoading = false;
4570
- if (this._hasManualSlug()) {
4571
- this._scheduleSlugAvailabilityCheck();
4810
+ if (this._hasPostManualSlug(index)) {
4811
+ this._scheduleSlugAvailabilityCheck(index);
4572
4812
  return;
4573
4813
  }
4574
4814
  this._scheduleSuggestedSlugRefresh();
4575
4815
  }
4576
4816
 
4577
- private _onPublishedAtInput(e: Event) {
4578
- this._publishedAtInput = (e.target as HTMLInputElement).value;
4579
- }
4580
-
4581
- private _resetPublishedAt() {
4817
+ private _resetPublishedAt(index = 0) {
4582
4818
  const currentTimestamp = this._getCurrentTimestamp();
4583
- this._publishedAtInput = toLocalDateInputValue(currentTimestamp);
4584
- this._publishedAtTimeMinutes = getTimestampTimeMinutes(currentTimestamp);
4819
+ this._setPostPublishedAtInput(
4820
+ index,
4821
+ toLocalDateInputValue(currentTimestamp),
4822
+ );
4823
+ this._setPostPublishedAtTimeMinutes(
4824
+ index,
4825
+ getTimestampTimeMinutes(currentTimestamp),
4826
+ );
4585
4827
  this.updateComplete.then(() => {
4586
4828
  this.querySelector<HTMLInputElement>(
4587
4829
  ".compose-publish-date-input",
@@ -4607,9 +4849,15 @@ export class JantComposeDialog extends LitElement {
4607
4849
  );
4608
4850
  }
4609
4851
 
4610
- private _renderPublishVisibilityChip(
4852
+ /**
4853
+ * One choice in the options sheet: title, one-line explanation, and a check
4854
+ * on the selected row. Every option carries its own hint so they can be
4855
+ * compared before choosing, which a single hint under a chip group can't do.
4856
+ */
4857
+ private _renderVisibilityRow(
4611
4858
  visibility: ComposeVisibility,
4612
4859
  label: string,
4860
+ hint: string,
4613
4861
  ) {
4614
4862
  const selected = this._visibility === visibility;
4615
4863
 
@@ -4617,8 +4865,8 @@ export class JantComposeDialog extends LitElement {
4617
4865
  <button
4618
4866
  type="button"
4619
4867
  class=${classMap({
4620
- "compose-publish-chip": true,
4621
- "compose-publish-chip-selected": selected,
4868
+ "compose-sheet-row": true,
4869
+ "compose-sheet-row-selected": selected,
4622
4870
  })}
4623
4871
  role="radio"
4624
4872
  aria-checked=${selected ? "true" : "false"}
@@ -4626,168 +4874,212 @@ export class JantComposeDialog extends LitElement {
4626
4874
  @click=${() => this._setVisibility(visibility)}
4627
4875
  >
4628
4876
  ${this._renderVisibilityIcon(visibility)}
4629
- <span class="compose-publish-chip-label">${label}</span>
4877
+ <span class="compose-sheet-main">
4878
+ <span class="compose-sheet-title">${label}</span>
4879
+ <span class="compose-sheet-sub">${hint}</span>
4880
+ </span>
4881
+ ${selected
4882
+ ? html`<span class="compose-sheet-check" aria-hidden="true">
4883
+ ${renderComposePublishActionIcon(
4884
+ COMPOSE_PUBLISH_ACTION_ICONS.check,
4885
+ "compose-sheet-check-icon",
4886
+ )}
4887
+ </span>`
4888
+ : nothing}
4630
4889
  </button>
4631
4890
  `;
4632
4891
  }
4633
4892
 
4634
- private _getVisibilityHint(): string {
4635
- switch (this._visibility) {
4636
- case "public":
4637
- return this.labels.publishVisibilityPublicHint;
4638
- case "latest_hidden":
4639
- return this.labels.publishVisibilityHiddenFromLatestHint;
4640
- case "private":
4641
- return this.labels.publishVisibilityPrivateHint;
4642
- }
4893
+ /**
4894
+ * The label above a field, with its reset on the right. The reset only shows
4895
+ * once there is something to reset, so the default state is a bare label.
4896
+ */
4897
+ private _renderMetaFieldHead(label: string, reset: unknown) {
4898
+ return html`
4899
+ <div class="compose-meta-field-head">
4900
+ <span class="compose-meta-label">${label}</span>
4901
+ ${reset}
4902
+ </div>
4903
+ `;
4643
4904
  }
4644
4905
 
4645
- private _renderPublishDateSection() {
4646
- const publishedAtError = this._getPublishedAtValidationMessage();
4906
+ /**
4907
+ * Chrome opens the calendar only from its own indicator glyph — a ~13px
4908
+ * target that reads as a browser default next to everything else in this
4909
+ * panel, and that other engines place differently or not at all. The
4910
+ * indicator is hidden in CSS and this button drives the same picker through
4911
+ * `showPicker()`, so there is one target, it is ours, and it is the height of
4912
+ * the field. Typing a date into the field still works either way, which is
4913
+ * why the button is the only thing that opens the picker: taking over the
4914
+ * whole field would put the calendar in front of anyone reaching for the
4915
+ * keyboard.
4916
+ */
4917
+ private _openDatePicker(index: number) {
4918
+ const fields = this.querySelectorAll<HTMLInputElement>(
4919
+ ".compose-publish-date-input",
4920
+ );
4921
+ // One panel is open at a time, so the panel's own field is the only one in
4922
+ // the DOM; `index` is kept for the thread case if that ever changes.
4923
+ const input = fields[index] ?? fields[0];
4924
+ if (!input) return;
4647
4925
 
4648
- return html`
4649
- <section class="compose-publish-section">
4650
- <div class="compose-publish-section-header">
4651
- <div class="compose-publish-section-copy">
4652
- <p class="compose-publish-section-label">
4653
- ${this.labels.publishDateLabel}
4654
- </p>
4655
- <p class="compose-publish-section-hint">
4656
- ${this.labels.publishDateHint}
4657
- </p>
4658
- </div>
4659
- ${this._hasPublishedAtValue()
4660
- ? html`
4661
- <button
4662
- type="button"
4663
- class="compose-publish-section-action"
4664
- @click=${() => this._resetPublishedAt()}
4665
- >
4666
- ${this.labels.publishDateReset}
4667
- </button>
4668
- `
4669
- : nothing}
4670
- </div>
4671
- <div class="compose-publish-date-field">
4672
- <div class="compose-publish-date-input-wrap">
4673
- <input
4674
- type="date"
4675
- class="compose-input compose-publish-date-input"
4676
- .value=${this._publishedAtInput}
4677
- max=${this._getPublishedAtMaxInput()}
4678
- aria-invalid=${publishedAtError ? "true" : "false"}
4679
- @input=${(e: Event) => this._onPublishedAtInput(e)}
4680
- />
4681
- </div>
4682
- ${publishedAtError
4683
- ? html`<p
4684
- class=${classMap({
4685
- "compose-publish-date-status": true,
4686
- "compose-publish-date-status-error": true,
4687
- })}
4926
+ input.focus();
4927
+ try {
4928
+ input.showPicker();
4929
+ } catch {
4930
+ // Older engines, or a browser that declined: the field is still typable.
4931
+ }
4932
+ }
4933
+
4934
+ private _renderPublishDateSection(index: number) {
4935
+ const publishedAtError = this._getPostPublishedAtValidationMessage(index);
4936
+ const hasValue = this._hasPostPublishedAtValue(index);
4937
+
4938
+ return html`
4939
+ <div class="compose-meta-field compose-publish-date-field">
4940
+ ${this._renderMetaFieldHead(
4941
+ this.labels.publishDateLabel,
4942
+ hasValue
4943
+ ? html`<button
4944
+ type="button"
4945
+ class="compose-publish-section-action"
4946
+ @click=${() => this._resetPublishedAt(index)}
4688
4947
  >
4689
- ${publishedAtError}
4690
- </p>`
4691
- : nothing}
4948
+ ${this.labels.publishDateReset}
4949
+ </button>`
4950
+ : nothing,
4951
+ )}
4952
+ <div class="compose-publish-date-input-wrap">
4953
+ <input
4954
+ type="date"
4955
+ class=${classMap({
4956
+ "compose-input": true,
4957
+ "compose-publish-date-input": true,
4958
+ // The browser draws `yyyy-mm-dd` in the value's own colour, so an
4959
+ // empty field reads as a date that has been set. Dim it instead.
4960
+ "compose-publish-date-input-empty": !hasValue,
4961
+ })}
4962
+ .value=${this._getPostPublishedAtInput(index)}
4963
+ max=${this._getPublishedAtMaxInput()}
4964
+ aria-label=${this.labels.publishDateLabel}
4965
+ aria-invalid=${publishedAtError ? "true" : "false"}
4966
+ @input=${(e: Event) =>
4967
+ this._setPostPublishedAtInput(
4968
+ index,
4969
+ (e.target as HTMLInputElement).value,
4970
+ )}
4971
+ />
4972
+ <button
4973
+ type="button"
4974
+ class="compose-publish-date-picker"
4975
+ aria-label=${this.labels.publishDateSummaryAction}
4976
+ data-compose-date-picker
4977
+ @click=${() => this._openDatePicker(index)}
4978
+ >
4979
+ ${renderComposePublishActionIcon(
4980
+ COMPOSE_PUBLISH_ACTION_ICONS.calendar,
4981
+ "compose-publish-date-picker-icon",
4982
+ )}
4983
+ </button>
4692
4984
  </div>
4693
- </section>
4985
+ ${publishedAtError
4986
+ ? html`<p
4987
+ class="compose-publish-date-status compose-publish-date-status-error"
4988
+ >
4989
+ ${publishedAtError}
4990
+ </p>`
4991
+ : hasValue
4992
+ ? nothing
4993
+ : html`<p class="compose-sheet-hint">
4994
+ ${this.labels.publishDateHint}
4995
+ </p>`}
4996
+ </div>
4694
4997
  `;
4695
4998
  }
4696
4999
 
4697
- private _renderPublishSlugSection() {
4698
- const slugError = this._getSlugValidationMessage();
4699
- const slugStatus = this._getSlugStatusMessage();
4700
- const slugPreview = this._getSlugPreviewParts();
5000
+ private _renderPublishSlugSection(index: number) {
5001
+ const isRoot = index === 0;
5002
+ const slugError = this._getPostSlugValidationMessage(index);
5003
+ const slugStatus = isRoot ? this._getSlugStatusMessage() : slugError;
5004
+ const slugPreview = isRoot ? this._getSlugPreviewParts() : null;
5005
+ // Suggestions come from the title, which replies do not have.
4701
5006
  const showSuggestion =
5007
+ isRoot &&
4702
5008
  !this._hasManualSlug() &&
4703
5009
  !this._suggestedSlugLoading &&
4704
5010
  Boolean(this._suggestedSlug);
4705
5011
 
4706
5012
  return html`
4707
- <section class="compose-publish-section">
4708
- <div class="compose-publish-section-header">
4709
- <div class="compose-publish-section-copy">
4710
- <p class="compose-publish-section-label">
4711
- ${this.labels.publishSlugLabel}
4712
- </p>
4713
- <p class="compose-publish-section-hint">
4714
- ${this.labels.publishSlugHint}
4715
- </p>
4716
- </div>
4717
- ${this._hasManualSlug()
4718
- ? html`
4719
- <button
4720
- type="button"
4721
- class="compose-publish-section-action"
4722
- @click=${() => this._resetCustomSlug()}
4723
- >
4724
- ${this.labels.publishSlugReset}
4725
- </button>
4726
- `
4727
- : nothing}
5013
+ <div class="compose-meta-field compose-publish-slug-field">
5014
+ ${this._renderMetaFieldHead(
5015
+ this.labels.publishSlugLabel,
5016
+ this._hasManualSlug()
5017
+ ? html`<button
5018
+ type="button"
5019
+ class="compose-publish-section-action"
5020
+ @click=${() => this._resetCustomSlug()}
5021
+ >
5022
+ ${this.labels.publishSlugReset}
5023
+ </button>`
5024
+ : nothing,
5025
+ )}
5026
+ <div class="compose-publish-slug-input-wrap">
5027
+ <span class="compose-publish-slug-prefix" aria-hidden="true">/</span>
5028
+ <input
5029
+ type="text"
5030
+ class="compose-input compose-publish-slug-input"
5031
+ .value=${this._getPostSlug(index)}
5032
+ placeholder=${this.labels.publishSlugPlaceholder}
5033
+ aria-label=${this.labels.publishSlugLabel}
5034
+ aria-invalid=${slugError ? "true" : "false"}
5035
+ spellcheck="false"
5036
+ autocapitalize="off"
5037
+ autocomplete="off"
5038
+ @input=${(e: Event) => this._onSlugInput(e, index)}
5039
+ />
4728
5040
  </div>
4729
- <div class="compose-publish-slug-field">
4730
- <div class="compose-publish-slug-input-wrap">
4731
- <span class="compose-publish-slug-prefix">/</span>
4732
- <input
4733
- type="text"
4734
- class="compose-input compose-publish-slug-input"
4735
- .value=${this._slug}
4736
- placeholder=${this.labels.publishSlugPlaceholder}
4737
- aria-invalid=${slugError ? "true" : "false"}
4738
- spellcheck="false"
4739
- autocapitalize="off"
4740
- autocomplete="off"
4741
- @input=${(e: Event) => this._onSlugInput(e)}
4742
- />
4743
- </div>
4744
- ${showSuggestion
4745
- ? html`
4746
- <button
4747
- type="button"
4748
- class="compose-slug-suggestion"
4749
- @click=${() => this._useSuggestedSlug()}
4750
- >
4751
- <span class="compose-slug-suggestion-copy">
4752
- <span class="compose-slug-suggestion-label"
4753
- >${this.labels.publishSlugSuggested}</span
4754
- >
4755
- <span class="compose-slug-suggestion-chip">
4756
- <span class="compose-slug-suggestion-value"
4757
- >/${this._suggestedSlug}</span
4758
- >
4759
- </span>
4760
- </span>
4761
- <span class="compose-slug-suggestion-icon" aria-hidden="true">
4762
- <svg
4763
- width="14"
4764
- height="14"
4765
- viewBox="0 0 14 14"
4766
- fill="none"
4767
- stroke="currentColor"
4768
- stroke-width="1.4"
4769
- stroke-linecap="round"
4770
- stroke-linejoin="round"
4771
- >
4772
- <path d="M3 7h8" />
4773
- <path d="m8 3 4 4-4 4" />
4774
- </svg>
4775
- </span>
4776
- </button>
4777
- `
4778
- : nothing}
4779
- ${slugStatus
4780
- ? html`<p
4781
- class=${classMap({
4782
- "compose-publish-slug-status": true,
4783
- "compose-publish-slug-status-error": Boolean(slugError),
4784
- })}
4785
- data-compose-slug-error=${slugError ? "true" : nothing}
5041
+ ${showSuggestion
5042
+ ? html`
5043
+ <button
5044
+ type="button"
5045
+ class="compose-slug-suggestion"
5046
+ @click=${() => this._useSuggestedSlug()}
4786
5047
  >
4787
- ${slugStatus}
4788
- </p>`
4789
- : nothing}
4790
- ${slugPreview
5048
+ <span class="compose-slug-suggestion-label"
5049
+ >${this.labels.publishSlugSuggested}</span
5050
+ >
5051
+ <span class="compose-slug-suggestion-value"
5052
+ >/${this._suggestedSlug}</span
5053
+ >
5054
+ <span class="compose-slug-suggestion-icon" aria-hidden="true">
5055
+ <svg
5056
+ width="13"
5057
+ height="13"
5058
+ viewBox="0 0 14 14"
5059
+ fill="none"
5060
+ stroke="currentColor"
5061
+ stroke-width="1.4"
5062
+ stroke-linecap="round"
5063
+ stroke-linejoin="round"
5064
+ >
5065
+ <path d="M3 7h8" />
5066
+ <path d="m8 3 4 4-4 4" />
5067
+ </svg>
5068
+ </span>
5069
+ </button>
5070
+ `
5071
+ : nothing}
5072
+ ${slugStatus
5073
+ ? html`<p
5074
+ class=${classMap({
5075
+ "compose-publish-slug-status": true,
5076
+ "compose-publish-slug-status-error": Boolean(slugError),
5077
+ })}
5078
+ data-compose-slug-error=${slugError ? "true" : nothing}
5079
+ >
5080
+ ${slugStatus}
5081
+ </p>`
5082
+ : slugPreview
4791
5083
  ? html`<p
4792
5084
  class="compose-publish-slug-preview"
4793
5085
  data-compose-slug-preview
@@ -4799,79 +5091,576 @@ export class JantComposeDialog extends LitElement {
4799
5091
  >${slugPreview.path}</span
4800
5092
  >
4801
5093
  </p>`
4802
- : nothing}
4803
- </div>
4804
- </section>
5094
+ : this._hasPostManualSlug(index)
5095
+ ? nothing
5096
+ : html`<p class="compose-sheet-hint">
5097
+ ${this.labels.publishSlugHint}
5098
+ </p>`}
5099
+ </div>
4805
5100
  `;
4806
5101
  }
4807
5102
 
5103
+ /**
5104
+ * Replying quietly is an instruction for the moment a reply is written: the
5105
+ * server's only job is to skip the thread-activity bump it would otherwise
5106
+ * do on create. Saving an existing post goes through the update path, which
5107
+ * has no such bump to skip and no way to carry the intent — so the switch is
5108
+ * not offered there rather than sitting dead. A thread draft is the one
5109
+ * exception: saving it deletes and recreates every post, so the flag lands.
5110
+ */
5111
+ private _canReplyQuietly(): boolean {
5112
+ if (!this._replyToId) return false;
5113
+ const editsExistingPost = !!(this._editPostId || this._draftSourceId);
5114
+ return !editsExistingPost || this._threadItems.length >= 2;
5115
+ }
5116
+
4808
5117
  private _renderQuietReplySection() {
4809
- if (!this._replyToId) return nothing;
5118
+ if (!this._canReplyQuietly()) return nothing;
4810
5119
 
4811
5120
  return html`
4812
- <section class="compose-publish-section">
4813
- <label class="compose-publish-switch-row">
4814
- <div class="compose-publish-section-copy">
4815
- <p class="compose-publish-section-label">
4816
- ${this.labels.quietReplyLabel}
4817
- </p>
4818
- <p class="compose-publish-section-hint">
4819
- ${this.labels.quietReplyHint}
4820
- </p>
4821
- </div>
4822
- <input
4823
- type="checkbox"
4824
- role="switch"
4825
- class="input"
4826
- .checked=${this._quietReply}
4827
- @change=${(e: Event) => {
4828
- this._quietReply = (e.target as HTMLInputElement).checked;
4829
- }}
4830
- />
4831
- </label>
4832
- </section>
5121
+ <label class="compose-sheet-row compose-sheet-row-switch">
5122
+ <span class="compose-sheet-main">
5123
+ <span class="compose-sheet-title"
5124
+ >${this.labels.quietReplyLabel}</span
5125
+ >
5126
+ <span class="compose-sheet-sub">${this.labels.quietReplyHint}</span>
5127
+ </span>
5128
+ <input
5129
+ type="checkbox"
5130
+ role="switch"
5131
+ class="input"
5132
+ .checked=${this._quietReply}
5133
+ @change=${(e: Event) => {
5134
+ this._quietReply = (e.target as HTMLInputElement).checked;
5135
+ }}
5136
+ />
5137
+ </label>
4833
5138
  `;
4834
5139
  }
4835
5140
 
4836
- private _renderPublishPanelSections() {
5141
+ /**
5142
+ * Value shown on the collapsed "Published on" row. Always reflects the
5143
+ * current date — unlike the old summary chip, which deliberately stayed
5144
+ * silent when nothing had changed.
5145
+ */
5146
+ private _getPublishedAtRowValue(): string {
5147
+ const parsed = parseLocalDateInputValue(this._publishedAtInput);
5148
+ if (parsed === null) return this.labels.publishDateSummaryNow;
5149
+
5150
+ return new Intl.DateTimeFormat(undefined, {
5151
+ month: "short",
5152
+ day: "numeric",
5153
+ year: "numeric",
5154
+ }).format(new Date(parsed.year, parsed.monthIndex, parsed.day));
5155
+ }
5156
+
5157
+ /** Value shown on the collapsed "Custom link" row. */
5158
+ private _getPostSlugRowValue(index: number): string {
5159
+ const slug = this._getPostSlug(index).trim();
5160
+ return slug ? `/${slug}` : this.labels.publishSlugSummaryAuto;
5161
+ }
5162
+
5163
+ /* ── Per-post publish date ───────────────────────────────────────────
5164
+ Only the root carries a permalink control: a reply's slug is a random id
5165
+ assigned at publish time, and overriding it is not a thing people do. The
5166
+ date is — backfilling an old thread is the whole reason this exists. */
5167
+
5168
+ private _getPostSlug(index: number): string {
5169
+ if (index === 0) return this._slug;
5170
+ return this._threadItems[index]?.slug ?? "";
5171
+ }
5172
+
5173
+ private _setPostSlug(index: number, value: string) {
5174
+ if (index === 0) {
5175
+ this._slug = value;
5176
+ return;
5177
+ }
5178
+ this._threadItems = this._threadItems.map((item, i) =>
5179
+ i === index ? { ...item, slug: value } : item,
5180
+ );
5181
+ }
5182
+
5183
+ private _setPostSlugTaken(index: number, taken: boolean) {
5184
+ if (index === 0) {
5185
+ this._slugTaken = taken;
5186
+ return;
5187
+ }
5188
+ this._threadItems = this._threadItems.map((item, i) =>
5189
+ i === index ? { ...item, slugTaken: taken } : item,
5190
+ );
5191
+ }
5192
+
5193
+ private _hasPostManualSlug(index: number): boolean {
5194
+ return this._getPostSlug(index).trim().length > 0;
5195
+ }
5196
+
5197
+ private _getPostSlugSyncValidationMessage(index: number): string | null {
5198
+ const issue = getSlugValidationIssue(this._getPostSlug(index));
5199
+ if (issue === "invalid") return this.labels.publishSlugInvalid;
5200
+ if (issue === "reserved") return this.labels.publishSlugReserved;
5201
+ return null;
5202
+ }
5203
+
5204
+ private _getPostSlugValidationMessage(index: number): string | null {
5205
+ const sync = this._getPostSlugSyncValidationMessage(index);
5206
+ if (sync) return sync;
5207
+ const taken =
5208
+ index === 0 ? this._slugTaken : this._threadItems[index]?.slugTaken;
5209
+ if (this._hasPostManualSlug(index) && taken) {
5210
+ return this.labels.publishSlugTaken;
5211
+ }
5212
+ return null;
5213
+ }
5214
+
5215
+ private _getPostPublishedAtInput(index: number): string {
5216
+ if (index === 0) return this._publishedAtInput;
5217
+ return this._threadItems[index]?.publishedAtInput ?? "";
5218
+ }
5219
+
5220
+ private _setPostPublishedAtInput(index: number, value: string) {
5221
+ if (index === 0) {
5222
+ this._publishedAtInput = value;
5223
+ return;
5224
+ }
5225
+ this._threadItems = this._threadItems.map((item, i) =>
5226
+ i === index ? { ...item, publishedAtInput: value } : item,
5227
+ );
5228
+ }
5229
+
5230
+ private _setPostPublishedAtTimeMinutes(index: number, value: number | null) {
5231
+ if (index === 0) {
5232
+ this._publishedAtTimeMinutes = value;
5233
+ return;
5234
+ }
5235
+ this._threadItems = this._threadItems.map((item, i) =>
5236
+ i === index ? { ...item, publishedAtTimeMinutes: value } : item,
5237
+ );
5238
+ }
5239
+
5240
+ private _hasPostPublishedAtValue(index: number): boolean {
5241
+ return this._getPostPublishedAtInput(index).trim().length > 0;
5242
+ }
5243
+
5244
+ /** Same rules as the root's, applied to any post in the thread. */
5245
+ private _getPostPublishedAtValidationMessage(index: number): string | null {
5246
+ const input = this._getPostPublishedAtInput(index);
5247
+ if (!input.trim()) return null;
5248
+ if (parseLocalDateInputValue(input) === null) {
5249
+ return this.labels.publishDateInvalid;
5250
+ }
5251
+ if (input > this._getPublishedAtMaxInput()) {
5252
+ return this.labels.publishDateFutureError;
5253
+ }
5254
+ return null;
5255
+ }
5256
+
5257
+ private _getPostPublishedAtRowValue(index: number): string {
5258
+ const parsed = parseLocalDateInputValue(
5259
+ this._getPostPublishedAtInput(index),
5260
+ );
5261
+ if (parsed !== null) {
5262
+ return new Intl.DateTimeFormat(undefined, {
5263
+ month: "short",
5264
+ day: "numeric",
5265
+ year: "numeric",
5266
+ }).format(new Date(parsed.year, parsed.monthIndex, parsed.day));
5267
+ }
5268
+ // A reply with no date of its own inherits the root's — say so rather than
5269
+ // implying it publishes "now" independently.
5270
+ if (index > 0 && this._hasPublishedAtValue()) {
5271
+ return this._getPublishedAtRowValue();
5272
+ }
5273
+ return this.labels.publishDateSummaryNow;
5274
+ }
5275
+
5276
+ /**
5277
+ * Date (and, on the root, permalink) describe one post, so they live on that
5278
+ * post rather than in the publish panel, which speaks for the whole
5279
+ * submission. The control stays quiet until the post is hovered, focused, or
5280
+ * carries a non-default value.
5281
+ */
5282
+ private _renderPostMetaControl(index: number) {
5283
+ if (this._openingEdit) return nothing;
5284
+
5285
+ const isRoot = index === 0;
5286
+ const open = this._postMetaIndex === index;
5287
+ const custom =
5288
+ this._hasPostPublishedAtValue(index) || this._hasPostManualSlug(index);
5289
+
4837
5290
  return html`
4838
- ${this._replyToId ? this._renderQuietReplySection() : nothing}
4839
- ${this._visibilityLocked
4840
- ? nothing
4841
- : html`
4842
- <section class="compose-publish-section">
4843
- <div class="compose-publish-section-header">
4844
- <div class="compose-publish-section-copy">
4845
- <p class="compose-publish-section-label">
4846
- ${this.labels.publishVisibilityLabel}
4847
- </p>
4848
- </div>
5291
+ <div
5292
+ class=${classMap({
5293
+ "compose-post-meta": true,
5294
+ "compose-post-meta-set": custom,
5295
+ "compose-post-meta-open": open,
5296
+ })}
5297
+ >
5298
+ ${open
5299
+ ? html`<div
5300
+ class="compose-dropdown-backdrop"
5301
+ @click=${() => this._closePostMeta()}
5302
+ ></div>`
5303
+ : nothing}
5304
+ <button
5305
+ type="button"
5306
+ class="compose-post-meta-pill"
5307
+ aria-haspopup="dialog"
5308
+ aria-expanded=${open ? "true" : "false"}
5309
+ data-compose-post-meta-pill
5310
+ data-post-index=${index}
5311
+ @click=${() => {
5312
+ if (open) return this._closePostMeta();
5313
+ this._showPublishPanel = false;
5314
+ this._showCollection = false;
5315
+ this._postMetaIndex = index;
5316
+ if (isRoot) this._scheduleSuggestedSlugRefresh(true);
5317
+ // Focus the panel, not its first field: a focused `type="date"`
5318
+ // opens with its year segment highlighted in the OS accent colour,
5319
+ // which Chrome will not let us restyle. Tab reaches the fields.
5320
+ this._placeAndFocusPostMetaPanel();
5321
+ }}
5322
+ >
5323
+ ${renderComposePublishActionIcon(
5324
+ COMPOSE_PUBLISH_ACTION_ICONS.calendar,
5325
+ "compose-post-meta-icon",
5326
+ )}
5327
+ <span class="compose-post-meta-value"
5328
+ >${this._getPostPublishedAtRowValue(index)}</span
5329
+ >
5330
+ <span class="compose-post-meta-sep" aria-hidden="true">·</span>
5331
+ <!-- No link icon: the permalink already renders with its leading
5332
+ slash, and two icons in a control this quiet read as clutter. -->
5333
+ <span class="compose-post-meta-value compose-post-meta-value-slug"
5334
+ >${this._getPostSlugRowValue(index)}</span
5335
+ >
5336
+ </button>
5337
+ ${open
5338
+ ? html`<div
5339
+ class="compose-post-meta-panel"
5340
+ role="dialog"
5341
+ aria-label=${this.labels.publishSettings}
5342
+ tabindex="-1"
5343
+ data-compose-post-meta-panel
5344
+ >
5345
+ ${this._renderPublishDateSection(index)}
5346
+ ${this._renderPublishSlugSection(index)}
5347
+ <div class="compose-post-meta-footer">
5348
+ <button
5349
+ type="button"
5350
+ class="compose-publish-done"
5351
+ data-compose-post-meta-done
5352
+ @click=${() => this._closePostMeta()}
5353
+ >
5354
+ ${this.labels.done}
5355
+ </button>
4849
5356
  </div>
4850
- <div class="compose-publish-chips" role="radiogroup">
4851
- ${this._renderPublishVisibilityChip(
5357
+ </div>`
5358
+ : nothing}
5359
+ </div>
5360
+ `;
5361
+ }
5362
+
5363
+ /**
5364
+ * Places the just-opened date/permalink panel, then puts focus in it.
5365
+ *
5366
+ * Both wait on `updateComplete` because the panel is not ours to render: it
5367
+ * is handed to the post's editor row as `headerExtra`, so it only reaches the
5368
+ * DOM on the editor's own update, one below this one. Focus is `preventScroll`
5369
+ * — the panel is already pinned where it should be, and without it the browser
5370
+ * scrolls `.compose-scroll` to "reveal" a panel that is not in that box,
5371
+ * taking the format switcher off the top of the composer with it.
5372
+ */
5373
+ private _placeAndFocusPostMetaPanel(focusSelector?: string) {
5374
+ this.updateComplete.then(() => {
5375
+ this._updatePostMetaPanelLayout();
5376
+ this.querySelector<HTMLElement>(
5377
+ focusSelector ?? "[data-compose-post-meta-panel]",
5378
+ )?.focus({ preventScroll: true });
5379
+ });
5380
+ }
5381
+
5382
+ /**
5383
+ * Pins the open date/permalink panel under the pill it belongs to.
5384
+ *
5385
+ * The pill sits inside `.compose-scroll`, and that box scrolls: an absolutely
5386
+ * positioned panel is clipped by it, and an empty post makes the box shorter
5387
+ * than the panel every time, so the whole footer — Done included — used to be
5388
+ * cut off. `position: fixed` takes the panel out of the scroller, which hands
5389
+ * the coordinates to us.
5390
+ *
5391
+ * They are not viewport coordinates. The dialog's open animation leaves a
5392
+ * transform behind, and a transformed ancestor becomes the containing block
5393
+ * for `fixed` descendants — so the panel is parked at 0,0 first and the rect
5394
+ * that comes back is the origin everything else is measured from. Reading it
5395
+ * beats naming the ancestor: the page-mode composer has no dialog at all.
5396
+ */
5397
+ private _updatePostMetaPanelLayout() {
5398
+ const index = this._postMetaIndex;
5399
+ if (index === null) return;
5400
+
5401
+ const panel = this.querySelector<HTMLElement>(
5402
+ "[data-compose-post-meta-panel]",
5403
+ );
5404
+ const pill = this.querySelector<HTMLElement>(
5405
+ `[data-compose-post-meta-pill][data-post-index="${index}"]`,
5406
+ );
5407
+ if (!panel || !pill) return;
5408
+
5409
+ const visualViewport = globalThis.visualViewport;
5410
+ const viewportTop = visualViewport?.offsetTop ?? 0;
5411
+ const viewportLeft = visualViewport?.offsetLeft ?? 0;
5412
+ const viewportBottom =
5413
+ viewportTop + (visualViewport?.height ?? globalThis.innerHeight);
5414
+ const viewportRight =
5415
+ viewportLeft + (visualViewport?.width ?? globalThis.innerWidth);
5416
+ const edgePadding = 12;
5417
+ const gap = 6;
5418
+
5419
+ // Same rule as the publish panel: open into whichever side has more room,
5420
+ // and cap the panel at what that side actually offers so a short window
5421
+ // scrolls the panel's own body instead of hiding its footer.
5422
+ const pillRect = pill.getBoundingClientRect();
5423
+ const availableAbove = Math.max(
5424
+ 0,
5425
+ pillRect.top - (viewportTop + edgePadding) - gap,
5426
+ );
5427
+ const availableBelow = Math.max(
5428
+ 0,
5429
+ viewportBottom - edgePadding - pillRect.bottom - gap,
5430
+ );
5431
+ const direction = availableBelow >= availableAbove ? "down" : "up";
5432
+ const maxHeight = Math.max(
5433
+ 1,
5434
+ Math.floor(direction === "up" ? availableAbove : availableBelow),
5435
+ );
5436
+
5437
+ panel.style.top = "0px";
5438
+ panel.style.left = "0px";
5439
+ const origin = panel.getBoundingClientRect();
5440
+ // The dialog opens with a `scale(0.97)` animation, so for a third of a
5441
+ // second every measured rect is smaller than the box it describes while the
5442
+ // offsets we write are not. `offsetWidth` is the same box before the
5443
+ // transform, so their ratio is the scale to divide back out.
5444
+ const scale = origin.width / (panel.offsetWidth || origin.width || 1) || 1;
5445
+
5446
+ panel.style.setProperty(
5447
+ "--compose-post-meta-panel-max-height",
5448
+ `${maxHeight / scale}px`,
5449
+ );
5450
+ // Read back rather than reuse `origin.height`: the cap above is what
5451
+ // decides how tall the panel actually is, and a panel opening upwards is
5452
+ // placed from its own bottom edge.
5453
+ const height = panel.offsetHeight * scale;
5454
+
5455
+ const top =
5456
+ direction === "down"
5457
+ ? pillRect.bottom + gap
5458
+ : pillRect.top - gap - height;
5459
+ // Right-aligned to the pill, which is itself flush with the composer's
5460
+ // right edge — then held inside the viewport, since a narrow screen can
5461
+ // put the panel's left edge past it.
5462
+ const left = Math.min(
5463
+ Math.max(pillRect.right - origin.width, viewportLeft + edgePadding),
5464
+ Math.max(viewportRight - edgePadding - origin.width, viewportLeft),
5465
+ );
5466
+
5467
+ panel.style.top = `${(top - origin.top) / scale}px`;
5468
+ panel.style.left = `${(left - origin.left) / scale}px`;
5469
+ }
5470
+
5471
+ /**
5472
+ * Hands focus back to the post the panel belongs to. The panel takes focus
5473
+ * when it opens, so without this the closing panel takes the focus with it as
5474
+ * it leaves the DOM: focus lands on `<body>`, the editor loses
5475
+ * `:focus-within`, and on a wide screen the pill — quiet until hovered or
5476
+ * focused — fades out from under the pointer. It also leaves the compose
5477
+ * element entirely, which matters more: the `keydown` listener is bound to
5478
+ * this component, so Escape would stop closing compose afterwards.
5479
+ */
5480
+ private _closePostMeta() {
5481
+ const index = this._postMetaIndex;
5482
+ if (index === null) return;
5483
+
5484
+ const heldFocus =
5485
+ this.querySelector("[data-compose-post-meta-panel]")?.contains(
5486
+ document.activeElement,
5487
+ ) ?? false;
5488
+ this._postMetaIndex = null;
5489
+ if (!heldFocus) return;
5490
+
5491
+ this.updateComplete.then(() => {
5492
+ const editors = this.querySelectorAll<JantComposeEditor>(
5493
+ "jant-compose-editor",
5494
+ );
5495
+ (editors[index] ?? editors[0])?.focusInput();
5496
+ });
5497
+ }
5498
+
5499
+ /**
5500
+ * Whether the settings panel would have anything in it.
5501
+ *
5502
+ * Asks the row renderers instead of restating their conditions, so it cannot
5503
+ * drift out of sync with what the panel actually shows — the same reason
5504
+ * `_renderQuickActionsRow` inspects its children rather than recomputing
5505
+ * them. Editing a thread reply empties every row (visibility belongs to the
5506
+ * root, the draft rows only apply to unsaved posts), and an Options button
5507
+ * that opens a blank sheet is worse than no button.
5508
+ */
5509
+ private _hasPublishPanelContent(): boolean {
5510
+ if (!this._visibilityLocked) return true;
5511
+ if (this._replyToId && this._renderQuietReplySection() !== nothing) {
5512
+ return true;
5513
+ }
5514
+ return (
5515
+ this._renderSaveDraftRow() !== nothing ||
5516
+ this._renderDraftsRow() !== nothing ||
5517
+ this._renderCloseComposeRow() !== nothing
5518
+ );
5519
+ }
5520
+
5521
+ /**
5522
+ * A vertical list, not a toolbar: grouped by a muted label, one row per
5523
+ * setting, value or control on the right.
5524
+ */
5525
+ private _renderPublishPanelSections() {
5526
+ const divider = html`<div
5527
+ class="compose-sheet-divider"
5528
+ aria-hidden="true"
5529
+ ></div>`;
5530
+ const saveDraftRow = this._renderSaveDraftRow();
5531
+ const draftsRow = this._renderDraftsRow();
5532
+ const closeRow = this._renderCloseComposeRow();
5533
+ const hasSessionRows =
5534
+ saveDraftRow !== nothing || draftsRow !== nothing || closeRow !== nothing;
5535
+
5536
+ return html`
5537
+ <div class="compose-sheet">
5538
+ ${this._visibilityLocked
5539
+ ? nothing
5540
+ : html`
5541
+ <p class="compose-sheet-label">
5542
+ ${this.labels.publishVisibilityLabel}
5543
+ </p>
5544
+ <div
5545
+ role="radiogroup"
5546
+ aria-label=${this.labels.publishVisibilityLabel}
5547
+ >
5548
+ ${this._renderVisibilityRow(
4852
5549
  "public",
4853
5550
  this.labels.publishVisibilityPublic,
5551
+ this.labels.publishVisibilityPublicHint,
4854
5552
  )}
4855
- ${this._renderPublishVisibilityChip(
5553
+ ${this._renderVisibilityRow(
4856
5554
  "latest_hidden",
4857
5555
  this.labels.publishVisibilityHiddenFromLatest,
5556
+ this.labels.publishVisibilityHiddenFromLatestHint,
4858
5557
  )}
4859
- ${this._renderPublishVisibilityChip(
5558
+ ${this._renderVisibilityRow(
4860
5559
  "private",
4861
5560
  this.labels.publishVisibilityPrivate,
5561
+ this.labels.publishVisibilityPrivateHint,
4862
5562
  )}
4863
5563
  </div>
4864
- <p class="compose-publish-chip-hint">
4865
- ${this._getVisibilityHint()}
4866
- </p>
4867
- </section>
4868
- `}
4869
- ${this._visibilityLocked && !this._replyToId
4870
- ? nothing
4871
- : html`<div class="compose-publish-divider" aria-hidden="true"></div>`}
4872
- ${this._renderPublishDateSection()}
4873
- <div class="compose-publish-divider" aria-hidden="true"></div>
4874
- ${this._renderPublishSlugSection()}
5564
+ `}
5565
+ ${this._replyToId
5566
+ ? html`${this._visibilityLocked ? nothing : divider}
5567
+ ${this._renderQuietReplySection()}`
5568
+ : nothing}
5569
+ ${hasSessionRows
5570
+ ? html`${divider} ${saveDraftRow} ${draftsRow} ${closeRow}`
5571
+ : nothing}
5572
+ </div>
5573
+ `;
5574
+ }
5575
+
5576
+ /**
5577
+ * "Save as draft" is an action on what is in front of you; "Drafts" is a
5578
+ * place to go. One row cannot be both, which is what the old title-bar button
5579
+ * tried to do: it read "Save as draft" and then dropped you in the list. Once
5580
+ * there is something to save, both are offered and each does one thing.
5581
+ */
5582
+ private _renderSaveDraftRow() {
5583
+ if (this._editPostId || !this._hasContent()) return nothing;
5584
+
5585
+ return html`
5586
+ <button
5587
+ type="button"
5588
+ class="compose-sheet-row"
5589
+ ?disabled=${this._loading}
5590
+ @click=${() => {
5591
+ this._closePublishPanel(false);
5592
+ void this._submit("draft");
5593
+ }}
5594
+ >
5595
+ ${renderComposeSheetRowIcon(COMPOSE_SHEET_ROW_ICONS.saveDraft)}
5596
+ <span class="compose-sheet-main">
5597
+ <span class="compose-sheet-title">${this.labels.saveAsDraft}</span>
5598
+ </span>
5599
+ </button>
5600
+ `;
5601
+ }
5602
+
5603
+ /**
5604
+ * Drafts moved off the title bar and in here when that bar went away. It is
5605
+ * the rarer of the two things the bar held — a session action, not part of
5606
+ * writing — so it belongs behind the same trigger as the rest of them.
5607
+ *
5608
+ * Leaving unsaved work behind still asks first, but that prompt is now the
5609
+ * fallback rather than the only path: `_renderSaveDraftRow` is sitting right
5610
+ * above it for anyone who already knows what they want.
5611
+ */
5612
+ private _renderDraftsRow() {
5613
+ if (this._editPostId) return nothing;
5614
+
5615
+ return html`
5616
+ <button
5617
+ type="button"
5618
+ class="compose-sheet-row"
5619
+ ?disabled=${this._loading}
5620
+ @click=${() => {
5621
+ this._closePublishPanel(false);
5622
+ this._handleDraftButtonClick();
5623
+ }}
5624
+ >
5625
+ ${renderComposeSheetRowIcon(COMPOSE_SHEET_ROW_ICONS.drafts)}
5626
+ <span class="compose-sheet-main">
5627
+ <span class="compose-sheet-title">${this.labels.drafts}</span>
5628
+ </span>
5629
+ ${renderComposePublishActionIcon(
5630
+ COMPOSE_PUBLISH_ACTION_ICONS.caretRight,
5631
+ "compose-sheet-caret",
5632
+ )}
5633
+ </button>
5634
+ `;
5635
+ }
5636
+
5637
+ /**
5638
+ * A thread's posts each spend their × on removing themselves, so this is the
5639
+ * only visible exit there — and the only one at all on a phone, which has no
5640
+ * Escape key and no backdrop to tap. It routes through `requestClose`, so
5641
+ * unsaved work still gets the "Save to drafts?" prompt.
5642
+ */
5643
+ private _renderCloseComposeRow() {
5644
+ if (this._threadItems.length === 0) return nothing;
5645
+
5646
+ return html`
5647
+ <button
5648
+ type="button"
5649
+ class="compose-sheet-row"
5650
+ ?disabled=${this._loading}
5651
+ @click=${() => {
5652
+ this._closePublishPanel(false);
5653
+ this.requestClose();
5654
+ }}
5655
+ >
5656
+ ${renderComposePublishActionIcon(
5657
+ COMPOSE_PUBLISH_ACTION_ICONS.close,
5658
+ "compose-sheet-row-icon",
5659
+ )}
5660
+ <span class="compose-sheet-main">
5661
+ <span class="compose-sheet-title">${this.labels.closeCompose}</span>
5662
+ </span>
5663
+ </button>
4875
5664
  `;
4876
5665
  }
4877
5666
 
@@ -4890,15 +5679,6 @@ export class JantComposeDialog extends LitElement {
4890
5679
  data-compose-publish-panel-desktop
4891
5680
  >
4892
5681
  ${this._renderPublishPanelSections()}
4893
- <div class="compose-publish-panel-footer">
4894
- <button
4895
- type="button"
4896
- class="compose-publish-done"
4897
- @click=${() => this._closePublishPanel(true)}
4898
- >
4899
- ${this.labels.done}
4900
- </button>
4901
- </div>
4902
5682
  </div>
4903
5683
  `;
4904
5684
  }
@@ -4953,75 +5733,6 @@ export class JantComposeDialog extends LitElement {
4953
5733
  `;
4954
5734
  }
4955
5735
 
4956
- private _renderPublishSummary(summary: ComposePublishSummaryChip) {
4957
- const isPublishedAt = summary.kind === "publishedAt";
4958
-
4959
- return html`
4960
- <button
4961
- type="button"
4962
- class="compose-publish-summary"
4963
- data-compose-publish-summary=${summary.kind}
4964
- data-compose-publish-date-summary=${isPublishedAt ? "" : nothing}
4965
- data-compose-publish-slug-summary=${isPublishedAt ? nothing : ""}
4966
- data-publish-summary-value=${summary.value}
4967
- data-publish-date=${isPublishedAt ? summary.value : nothing}
4968
- data-publish-slug=${isPublishedAt ? nothing : summary.value}
4969
- aria-label=${summary.actionLabel}
4970
- title=${summary.actionLabel}
4971
- ?disabled=${this._loading}
4972
- @click=${() =>
4973
- isPublishedAt
4974
- ? this._revealPublishedAtField()
4975
- : this._revealSlugField()}
4976
- >
4977
- <span class="compose-publish-summary-icon" aria-hidden="true">
4978
- ${isPublishedAt
4979
- ? html`<svg
4980
- width="14"
4981
- height="14"
4982
- viewBox="0 0 16 16"
4983
- fill="none"
4984
- stroke="currentColor"
4985
- stroke-width="1.35"
4986
- stroke-linecap="round"
4987
- stroke-linejoin="round"
4988
- >
4989
- <rect x="2.75" y="3.45" width="10.5" height="9.8" rx="2.2" />
4990
- <path d="M5.35 2.55v2.1" />
4991
- <path d="M10.65 2.55v2.1" />
4992
- <path d="M2.75 6.2h10.5" />
4993
- </svg>`
4994
- : html`<svg
4995
- width="14"
4996
- height="14"
4997
- viewBox="0 0 16 16"
4998
- fill="none"
4999
- stroke="currentColor"
5000
- stroke-width="1.35"
5001
- stroke-linecap="round"
5002
- stroke-linejoin="round"
5003
- >
5004
- <path d="M9.75 4.15h1.35a2.75 2.75 0 0 1 0 5.5H9.75" />
5005
- <path d="M6.25 9.65H4.9a2.75 2.75 0 1 1 0-5.5h1.35" />
5006
- <path d="M5.65 8h4.7" />
5007
- </svg>`}
5008
- </span>
5009
- <span class="compose-publish-summary-text">${summary.text}</span>
5010
- </button>
5011
- `;
5012
- }
5013
-
5014
- private _renderPublishSummaries() {
5015
- const summaries = this._getPublishSummaryChips();
5016
- if (summaries.length === 0) return nothing;
5017
-
5018
- return html`
5019
- <div class="compose-publish-summaries">
5020
- ${summaries.map((summary) => this._renderPublishSummary(summary))}
5021
- </div>
5022
- `;
5023
- }
5024
-
5025
5736
  private _updatePublishPanelLayout() {
5026
5737
  if (this._publishPanelFullscreen) return;
5027
5738
 
@@ -5075,7 +5786,6 @@ export class JantComposeDialog extends LitElement {
5075
5786
 
5076
5787
  return html`
5077
5788
  <div class="compose-publish-shell">
5078
- ${this._renderPublishSummaries()}
5079
5789
  <div
5080
5790
  class=${classMap({
5081
5791
  "compose-publish-group": true,
@@ -5106,31 +5816,50 @@ export class JantComposeDialog extends LitElement {
5106
5816
  >
5107
5817
  ${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
5108
5818
  </button>
5109
- <button
5110
- type="button"
5111
- class=${classMap({
5112
- "compose-publish-toggle": true,
5113
- "compose-publish-toggle-loading": this._loading,
5114
- })}
5115
- ?disabled=${this._loading}
5116
- aria-haspopup="dialog"
5117
- aria-expanded=${this._showPublishPanel ? "true" : "false"}
5118
- aria-label=${this.labels.publishSettings}
5119
- title=${this.labels.publishSettings}
5120
- @click=${() => this._togglePublishPanel()}
5121
- >
5122
- ${renderComposePublishActionIcon(
5123
- COMPOSE_PUBLISH_ACTION_ICONS.chevron,
5124
- "compose-publish-toggle-chevron",
5125
- )}
5126
- </button>
5127
5819
  </div>
5820
+ <!-- Options sits past Publish as its own control rather than a
5821
+ chevron welded to it: a split button reads as one button, which
5822
+ is why nobody found the settings. -->
5823
+ ${this._hasPublishPanelContent()
5824
+ ? html`<button
5825
+ type="button"
5826
+ class=${classMap({
5827
+ "compose-options-trigger": true,
5828
+ "compose-options-trigger-open": this._showPublishPanel,
5829
+ })}
5830
+ ?disabled=${this._loading}
5831
+ aria-haspopup="dialog"
5832
+ aria-expanded=${this._showPublishPanel ? "true" : "false"}
5833
+ aria-label=${this.labels.publishSettings}
5834
+ title=${this.labels.publishSettings}
5835
+ @click=${() => this._togglePublishPanel()}
5836
+ >
5837
+ <svg
5838
+ class="compose-options-trigger-icon"
5839
+ width="22"
5840
+ height="22"
5841
+ viewBox="0 0 24 24"
5842
+ fill="none"
5843
+ stroke="currentColor"
5844
+ stroke-linecap="round"
5845
+ stroke-linejoin="round"
5846
+ aria-hidden="true"
5847
+ >
5848
+ ${unsafeSVG(COMPOSE_PUBLISH_ACTION_ICONS.options)}
5849
+ </svg>
5850
+ </button>`
5851
+ : nothing}
5128
5852
  ${this._renderDesktopPublishPanel()}
5129
5853
  </div>
5130
5854
  </div>
5131
5855
  `;
5132
5856
  }
5133
5857
 
5858
+ /**
5859
+ * Shortcuts for the two settings that get reached for constantly, sitting
5860
+ * under Publish where they can be flipped without opening the panel. Both are
5861
+ * mirrors of a row inside it, not a second source of truth.
5862
+ */
5134
5863
  private _renderQuickActionsRow() {
5135
5864
  const hideFromLatest = this._renderHideFromLatestQuickToggle();
5136
5865
  const quietReply = this._renderQuietReplyQuickToggle();
@@ -5143,7 +5872,7 @@ export class JantComposeDialog extends LitElement {
5143
5872
  }
5144
5873
 
5145
5874
  private _renderQuietReplyQuickToggle() {
5146
- if (!this._replyToId) return nothing;
5875
+ if (!this._canReplyQuietly()) return nothing;
5147
5876
  return html`
5148
5877
  <label class="compose-publish-quick-toggle">
5149
5878
  <input
@@ -5160,6 +5889,11 @@ export class JantComposeDialog extends LitElement {
5160
5889
  `;
5161
5890
  }
5162
5891
 
5892
+ /**
5893
+ * Visibility has three states, so a checkbox cannot speak for all of them: it
5894
+ * covers the public ↔ hidden-from-Latest pair and steps aside once the post
5895
+ * is private, where the panel's radio list is the only honest control.
5896
+ */
5163
5897
  private _renderHideFromLatestQuickToggle() {
5164
5898
  if (this._visibilityLocked) return nothing;
5165
5899
  if (this._visibility === "private") return nothing;
@@ -5190,6 +5924,12 @@ export class JantComposeDialog extends LitElement {
5190
5924
  aria-live="polite"
5191
5925
  aria-busy="true"
5192
5926
  >
5927
+ <!-- The post header this normally rides on has not rendered yet, so the
5928
+ close control gets its own row rather than leaving a fetch with no
5929
+ way out on a phone, where there is no Escape key. -->
5930
+ <div class="compose-edit-loading-close">
5931
+ ${this._renderCloseComposeControl()}
5932
+ </div>
5193
5933
  <div class="compose-edit-loading-status">
5194
5934
  <svg
5195
5935
  class="animate-spin size-5"
@@ -5311,11 +6051,9 @@ export class JantComposeDialog extends LitElement {
5311
6051
  "jant-compose-editor",
5312
6052
  );
5313
6053
  editors[this._focusedThreadIndex]?.focusInput();
5314
- const threadLayout = this.querySelector<HTMLElement>(
5315
- ".compose-thread-compose-layout",
5316
- );
5317
- if (threadLayout) {
5318
- threadLayout.scrollTop = threadLayout.scrollHeight;
6054
+ const scroller = this.querySelector<HTMLElement>(".compose-scroll");
6055
+ if (scroller) {
6056
+ scroller.scrollTop = scroller.scrollHeight;
5319
6057
  }
5320
6058
  });
5321
6059
  }
@@ -5386,7 +6124,6 @@ export class JantComposeDialog extends LitElement {
5386
6124
  singleEditor._showRating = capturedShowRating;
5387
6125
  }
5388
6126
  singleEditor.focusInput();
5389
- this.requestUpdate();
5390
6127
  });
5391
6128
  } else {
5392
6129
  this._threadItems = newItems;
@@ -5397,14 +6134,46 @@ export class JantComposeDialog extends LitElement {
5397
6134
  }
5398
6135
  }
5399
6136
 
6137
+ /**
6138
+ * Where this editor sits in the thread it is extending, as `3/3`.
6139
+ *
6140
+ * A reply continues a chain that already exists, so it counts from the end of
6141
+ * that chain, not from 1: replying to the second post of a thread makes the
6142
+ * third. Only the parent's own depth can tell us this, so the marker stays
6143
+ * hidden until `_loadReplyParentPosition` has read it back — a number that
6144
+ * starts at 2 and jumps to 3 is worse than one that arrives late.
6145
+ *
6146
+ * Empty when there is no chain to place the post in: a lone new post is not
6147
+ * "1/1", and an edit says the more useful thing through its "Editing" marker
6148
+ * (the post being edited may have replies below that compose cannot see, so a
6149
+ * total would be a guess).
6150
+ */
6151
+ private _positionLabel(index: number): string {
6152
+ if (this._editPostId) return "";
6153
+ const isReply = !!(this._replyToId && this._replyToData);
6154
+ if (isReply && this._replyParentPosition === null) return "";
6155
+ const posted = isReply ? (this._replyParentPosition ?? 0) : 0;
6156
+ const total = posted + (this._threadItems.length || 1);
6157
+ if (total < 2) return "";
6158
+ return `${posted + index + 1}/${total}`;
6159
+ }
6160
+
5400
6161
  private _renderThreadPost(
5401
6162
  item: ThreadItem,
5402
6163
  index: number,
5403
6164
  showRemove: boolean,
6165
+ startsThread: boolean,
5404
6166
  ) {
5405
6167
  return html`
5406
6168
  <div
5407
- class="compose-editor-row compose-thread-post"
6169
+ class=${classMap({
6170
+ "compose-editor-row": true,
6171
+ "compose-thread-post": true,
6172
+ // Marks the post the cursor is in, which is what the rail's accent
6173
+ // dot points at. Every post in a thread is equally "new", so being an
6174
+ // editor row is not what makes one of them current.
6175
+ "is-current": index === this._focusedThreadIndex,
6176
+ })}
5408
6177
  data-thread-index=${index}
5409
6178
  @focusin=${() => {
5410
6179
  this._focusedThreadIndex = index;
@@ -5432,6 +6201,10 @@ export class JantComposeDialog extends LitElement {
5432
6201
  e.stopPropagation();
5433
6202
  this._removeThreadItem(index);
5434
6203
  }}
6204
+ @jant:compose-status=${(e: CustomEvent<ComposeRowStatus>) => {
6205
+ e.stopPropagation();
6206
+ this._handleRowStatus(item.id, e.detail);
6207
+ }}
5435
6208
  >
5436
6209
  <div class="compose-thread-dot"></div>
5437
6210
  <jant-compose-editor
@@ -5440,6 +6213,9 @@ export class JantComposeDialog extends LitElement {
5440
6213
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5441
6214
  .threadItem=${true}
5442
6215
  .removable=${showRemove}
6216
+ .titleByDefault=${startsThread}
6217
+ .positionLabel=${this._positionLabel(index)}
6218
+ .headerExtra=${this._renderPostMetaControl(index)}
5443
6219
  .slashCommandDiscovered=${this.slashCommandDiscovered}
5444
6220
  data-thread-id=${item.id}
5445
6221
  ></jant-compose-editor>
@@ -5448,19 +6224,9 @@ export class JantComposeDialog extends LitElement {
5448
6224
  }
5449
6225
 
5450
6226
  private _renderAddToThreadRow() {
5451
- const PLUS_ICON = `<circle cx="8" cy="8" r="5.7"/><path d="M8 5.55v4.9M5.55 8h4.9"/>`;
5452
- const editors = this.querySelectorAll<JantComposeEditor>(
5453
- "jant-compose-editor",
5454
- );
5455
- const lastEditor = editors[editors.length - 1];
5456
- const lastData = lastEditor?.getData();
5457
- const lastEmpty =
5458
- !lastData ||
5459
- (!lastData.body &&
5460
- !lastData.title.trim() &&
5461
- !lastData.url.trim() &&
5462
- !lastData.quoteText.trim() &&
5463
- lastData.attachments.length === 0);
6227
+ const rowIds = this._rowIds;
6228
+ const lastId = rowIds[rowIds.length - 1];
6229
+ const lastEmpty = !this._rowStatus.get(lastId)?.hasContent;
5464
6230
  const atLimit = this._threadItems.length >= MAX_THREAD_POSTS;
5465
6231
  return html`
5466
6232
  <div class="compose-thread-add-row">
@@ -5471,19 +6237,6 @@ export class JantComposeDialog extends LitElement {
5471
6237
  ?disabled=${lastEmpty || atLimit}
5472
6238
  @click=${() => this._addThreadItem()}
5473
6239
  >
5474
- <svg
5475
- width="14"
5476
- height="14"
5477
- viewBox="0 0 16 16"
5478
- fill="none"
5479
- stroke="currentColor"
5480
- stroke-width="1.65"
5481
- stroke-linecap="round"
5482
- stroke-linejoin="round"
5483
- aria-hidden="true"
5484
- >
5485
- ${unsafeSVG(PLUS_ICON)}
5486
- </svg>
5487
6240
  Add to thread
5488
6241
  </button>
5489
6242
  </div>
@@ -5505,40 +6258,54 @@ export class JantComposeDialog extends LitElement {
5505
6258
  @jant:compose-content-changed=${() => this._scheduleDraftSave()}
5506
6259
  >
5507
6260
  ${isReply ? this._renderReplyContext() : nothing}
5508
- ${items.map((item, i) => this._renderThreadPost(item, i, showRemove))}
6261
+ <!-- Keyed by row id, not by position: an editor holds its own content,
6262
+ so an unkeyed list would reuse elements by position and drop the
6263
+ last one when a middle post is removed — taking that post's text
6264
+ with it, and pairing the survivors with the wrong dates and
6265
+ permalinks. -->
6266
+ ${repeat(
6267
+ items,
6268
+ (item) => item.id,
6269
+ (item, i) =>
6270
+ this._renderThreadPost(
6271
+ item,
6272
+ i,
6273
+ showRemove,
6274
+ // Only the post that opens a thread of its own gets a title field
6275
+ // by default — everything downstream of it continues a thought
6276
+ // that is already named.
6277
+ i === 0 && !isReply,
6278
+ ),
6279
+ )}
5509
6280
  ${this._renderAddToThreadRow()}
5510
6281
  </div>
5511
6282
  `;
5512
6283
  }
5513
6284
 
5514
- private _renderAddThreadTrigger() {
5515
- const PLUS_ICON = `<circle cx="8" cy="8" r="5.7"/><path d="M8 5.55v4.9M5.55 8h4.9"/>`;
6285
+ // `onRail` is for the reply composer, which is already a thread layout: the
6286
+ // row joins the rail with the same dashed dot thread compose uses, so the
6287
+ // placeholder sits where the next post will land instead of floating out at
6288
+ // the dialog's own left edge, a rail's width clear of everything above it.
6289
+ private _renderAddThreadTrigger(onRail: boolean) {
5516
6290
  const disabled = !this._hasContent();
5517
- return html`
5518
- <div class="compose-add-thread-trigger">
5519
- <button
5520
- type="button"
5521
- class="compose-add-thread-btn"
5522
- ?disabled=${disabled}
5523
- @click=${() => this._addThreadItem()}
5524
- >
5525
- <svg
5526
- width="14"
5527
- height="14"
5528
- viewBox="0 0 16 16"
5529
- fill="none"
5530
- stroke="currentColor"
5531
- stroke-width="1.65"
5532
- stroke-linecap="round"
5533
- stroke-linejoin="round"
5534
- aria-hidden="true"
5535
- >
5536
- ${unsafeSVG(PLUS_ICON)}
5537
- </svg>
5538
- Add to thread
5539
- </button>
5540
- </div>
6291
+ const button = html`
6292
+ <button
6293
+ type="button"
6294
+ class="compose-thread-add-btn"
6295
+ ?disabled=${disabled}
6296
+ @click=${() => this._addThreadItem()}
6297
+ >
6298
+ Add to thread
6299
+ </button>
5541
6300
  `;
6301
+ return onRail
6302
+ ? html`
6303
+ <div class="compose-thread-add-row">
6304
+ <div class="compose-thread-add-dot"></div>
6305
+ ${button}
6306
+ </div>
6307
+ `
6308
+ : html`<div class="compose-add-thread-trigger">${button}</div>`;
5542
6309
  }
5543
6310
 
5544
6311
  render() {
@@ -5550,8 +6317,34 @@ export class JantComposeDialog extends LitElement {
5550
6317
  .labels=${this.labels}
5551
6318
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5552
6319
  .inlineFormat=${isReply}
6320
+ .titleByDefault=${!isReply && JantComposeDialog._getNoteTitleDefault()}
6321
+ .positionLabel=${this._positionLabel(0)}
6322
+ .badgeLabel=${this._editPostId ? this.labels.editing : ""}
6323
+ .headerExtra=${html`${this._renderPostMetaControl(0)}
6324
+ ${this._renderCloseComposeControl()}`}
5553
6325
  .slashCommandDiscovered=${this.slashCommandDiscovered}
6326
+ @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
6327
+ e.stopPropagation();
6328
+ this._switchFormat(e.detail.format);
6329
+ }}
6330
+ @jant:compose-status=${(e: CustomEvent<ComposeRowStatus>) => {
6331
+ e.stopPropagation();
6332
+ this._handleRowStatus(SINGLE_ROW_ID, e.detail);
6333
+ }}
5554
6334
  ></jant-compose-editor>`;
6335
+ // The handler sits on the editor itself rather than a delegating wrapper.
6336
+ // Single-post mode renders this editor as a direct child of
6337
+ // `.compose-scroll`, and the rules that lay it out are keyed on that child
6338
+ // relationship — a wrapper breaks them even at `display: contents`, which
6339
+ // drops the box but not the DOM parentage a selector matches on.
6340
+ // (Thread mode builds its own editors in `_renderThreadPost`.)
6341
+
6342
+ // Thread mode grows its own row at the end of the layout, and editing an
6343
+ // existing post has no next slot to offer at all.
6344
+ const addThreadRow =
6345
+ isOpeningEdit || isThreadMode || this._editPostId
6346
+ ? nothing
6347
+ : this._renderAddThreadTrigger(isReply);
5555
6348
 
5556
6349
  return html`
5557
6350
  <div
@@ -5564,35 +6357,26 @@ export class JantComposeDialog extends LitElement {
5564
6357
  aria-hidden=${this._addCollectionPanelOpen ? "true" : "false"}
5565
6358
  ?inert=${this._addCollectionPanelOpen}
5566
6359
  >
5567
- ${this._renderHeader()}
5568
- ${isOpeningEdit
5569
- ? this._renderEditLoadingState()
5570
- : isThreadMode
5571
- ? this._renderThreadComposeLayout()
5572
- : isReply
5573
- ? html`
5574
- <div
5575
- class="compose-thread-layout compose-reply-compose-layout"
5576
- >
5577
- ${this._renderReplyContext()}
6360
+ <div class="compose-scroll">
6361
+ ${isOpeningEdit
6362
+ ? this._renderEditLoadingState()
6363
+ : isThreadMode
6364
+ ? this._renderThreadComposeLayout()
6365
+ : isReply
6366
+ ? html`
5578
6367
  <div
5579
- class="compose-editor-row"
5580
- @jant:format-change=${(
5581
- e: CustomEvent<{ format: ComposeFormat }>,
5582
- ) => {
5583
- e.stopPropagation();
5584
- this._switchFormat(e.detail.format);
5585
- }}
6368
+ class="compose-thread-layout compose-reply-compose-layout"
5586
6369
  >
5587
- <div class="compose-thread-dot"></div>
5588
- ${editor}
6370
+ ${this._renderReplyContext()}
6371
+ <div class="compose-editor-row is-current">
6372
+ <div class="compose-thread-dot"></div>
6373
+ ${editor}
6374
+ </div>
6375
+ ${addThreadRow}
5589
6376
  </div>
5590
- </div>
5591
- `
5592
- : editor}
5593
- ${isOpeningEdit || isThreadMode || this._editPostId
5594
- ? nothing
5595
- : this._renderAddThreadTrigger()}
6377
+ `
6378
+ : html`${editor}${addThreadRow}`}
6379
+ </div>
5596
6380
  ${isOpeningEdit
5597
6381
  ? nothing
5598
6382
  : html`<div