@jant/core 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-L1UPUArB.js → app-9P4rVCe2.js} +338 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-CSItbyU8.js} +357 -355
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/compose-format-convert.ts +255 -0
  29. package/src/client/components/compose-types.ts +2 -0
  30. package/src/client/components/jant-compose-dialog.ts +98 -44
  31. package/src/client/components/jant-compose-editor.ts +64 -11
  32. package/src/client/compose-bridge.ts +17 -0
  33. package/src/client/feed-video-player.ts +1 -1
  34. package/src/client/hydrate-partial.ts +25 -0
  35. package/src/client/note-expand.ts +63 -0
  36. package/src/client.ts +1 -0
  37. package/src/i18n/locales/public/en.po +41 -0
  38. package/src/i18n/locales/public/en.ts +1 -1
  39. package/src/i18n/locales/public/zh-Hans.po +41 -0
  40. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  41. package/src/i18n/locales/public/zh-Hant.po +41 -0
  42. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  43. package/src/i18n/locales/settings/en.po +12 -12
  44. package/src/i18n/locales/settings/en.ts +1 -1
  45. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  46. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  48. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  49. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  50. package/src/lib/__tests__/markdown.test.ts +1 -1
  51. package/src/lib/__tests__/summary.test.ts +87 -0
  52. package/src/lib/__tests__/timeline.test.ts +48 -1
  53. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  54. package/src/lib/__tests__/url.test.ts +44 -0
  55. package/src/lib/__tests__/view.test.ts +168 -1
  56. package/src/lib/navigation.ts +1 -0
  57. package/src/lib/resolve-config.ts +2 -2
  58. package/src/lib/summary.ts +42 -3
  59. package/src/lib/tiptap-render.ts +6 -2
  60. package/src/lib/upload.ts +2 -2
  61. package/src/lib/url.ts +41 -0
  62. package/src/lib/view.ts +102 -40
  63. package/src/preset.css +7 -1
  64. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  65. package/src/routes/api/internal/sites.ts +44 -1
  66. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  67. package/src/routes/api/public/archive.ts +22 -6
  68. package/src/routes/api/telegram.ts +2 -1
  69. package/src/routes/dash/custom-urls.tsx +1 -1
  70. package/src/routes/dash/settings.tsx +8 -5
  71. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  72. package/src/routes/pages/archive.tsx +116 -20
  73. package/src/routes/pages/collections.tsx +1 -0
  74. package/src/services/__tests__/media.test.ts +83 -0
  75. package/src/services/__tests__/post.test.ts +81 -0
  76. package/src/services/export-theme/assets/client-site.js +1 -1
  77. package/src/services/export-theme/styles/main.css +49 -15
  78. package/src/services/media.ts +31 -1
  79. package/src/services/post.ts +22 -2
  80. package/src/services/search.ts +4 -4
  81. package/src/services/upload-session.ts +18 -0
  82. package/src/styles/tokens.css +1 -1
  83. package/src/styles/ui.css +163 -34
  84. package/src/types/config.ts +1 -1
  85. package/src/types/props.ts +3 -0
  86. package/src/ui/compose/ComposeDialog.tsx +13 -0
  87. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  88. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  89. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  90. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  91. package/src/ui/feed/NoteCard.tsx +54 -5
  92. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  93. package/src/ui/pages/ArchivePage.tsx +89 -6
  94. package/src/ui/pages/CollectionsPage.tsx +7 -1
  95. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  96. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  97. package/src/ui/shared/CollectionsManager.tsx +3 -0
  98. package/dist/app-C1QgMNRY.js +0 -6
  99. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Compose format conversion
3
+ *
4
+ * When a post's format changes while editing (note / link / quote), each format
5
+ * stores a different subset of structured fields. This module converts those
6
+ * fields so nothing is silently lost:
7
+ *
8
+ * - **Fold** (when *leaving* a format): any field the target format can't hold is
9
+ * pushed into the body as a visible block (a blockquote, a heading, or a link
10
+ * paragraph) and the field is cleared. This never loses data.
11
+ * - **Extract** (when *entering* a format): only `blockquote → quoteText` is
12
+ * recovered from the body front, which keeps the common `quote → note → quote`
13
+ * round-trip lossless. url/title are not auto-extracted — they stay visible in
14
+ * the body and the author re-fills the field if needed.
15
+ *
16
+ * Pure and DOM-free so it can be unit-tested in isolation. It deep-clones the
17
+ * body it is given and never mutates its input.
18
+ */
19
+
20
+ import type { JSONContent } from "@tiptap/core";
21
+
22
+ import type { ComposeFormat } from "./compose-types.js";
23
+
24
+ /** The subset of compose fields that participate in format conversion. */
25
+ export interface ComposeConvertFields {
26
+ title: string;
27
+ url: string;
28
+ quoteText: string;
29
+ quoteAuthor: string;
30
+ showTitle: boolean;
31
+ bodyJson: JSONContent | null;
32
+ }
33
+
34
+ /** Matches an attribution paragraph: `— Author` or `— Author https://source`. */
35
+ const ATTRIBUTION_RE = /^—\s*(.*?)(?:\s+(https?:\/\/\S+))?\s*$/;
36
+
37
+ function cloneDoc(doc: JSONContent | null): JSONContent | null {
38
+ return doc ? (JSON.parse(JSON.stringify(doc)) as JSONContent) : null;
39
+ }
40
+
41
+ /** Concatenate all descendant text of a node. */
42
+ function nodeText(node: JSONContent | undefined): string {
43
+ if (!node) return "";
44
+ if (typeof node.text === "string") return node.text;
45
+ if (!Array.isArray(node.content)) return "";
46
+ return node.content.map(nodeText).join("");
47
+ }
48
+
49
+ function makeHeading(text: string, level = 2): JSONContent {
50
+ return {
51
+ type: "heading",
52
+ attrs: { level },
53
+ content: [{ type: "text", text }],
54
+ };
55
+ }
56
+
57
+ function makeParagraph(text: string): JSONContent {
58
+ return text
59
+ ? { type: "paragraph", content: [{ type: "text", text }] }
60
+ : { type: "paragraph" };
61
+ }
62
+
63
+ function makeLinkParagraph(url: string): JSONContent {
64
+ return {
65
+ type: "paragraph",
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: url,
70
+ marks: [{ type: "link", attrs: { href: url } }],
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ function parseAttribution(text: string): {
77
+ author: string;
78
+ url: string | null;
79
+ } {
80
+ const match = ATTRIBUTION_RE.exec(text.trim());
81
+ if (!match) return { author: "", url: null };
82
+ return { author: match[1].trim(), url: match[2] ?? null };
83
+ }
84
+
85
+ /** First link href found anywhere within a node (depth-first), or null. */
86
+ function findLinkHref(node: JSONContent | undefined): string | null {
87
+ if (!node) return null;
88
+ if (Array.isArray(node.marks)) {
89
+ const href = node.marks.find((mark) => mark.type === "link")?.attrs?.href;
90
+ if (typeof href === "string" && href) return href;
91
+ }
92
+ if (Array.isArray(node.content)) {
93
+ for (const child of node.content) {
94
+ const found = findLinkHref(child);
95
+ if (found) return found;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Build the `— Author` attribution paragraph for a folded quote. The author name
103
+ * is linked to the source url so the body stays clean (no raw url) while the
104
+ * `note → quote` round-trip can still recover the url from the link mark. When
105
+ * there is no author, the url itself becomes the link text. Null if neither.
106
+ */
107
+ function makeAttributionParagraph(
108
+ author: string,
109
+ url: string | null,
110
+ ): JSONContent | null {
111
+ const name = author.trim();
112
+ const href = url?.trim() ?? "";
113
+ if (!name && !href) return null;
114
+
115
+ const linked = (text: string): JSONContent => ({
116
+ type: "text",
117
+ text,
118
+ marks: [{ type: "link", attrs: { href } }],
119
+ });
120
+ if (name && href) {
121
+ return {
122
+ type: "paragraph",
123
+ content: [{ type: "text", text: "— " }, linked(name)],
124
+ };
125
+ }
126
+ if (name) {
127
+ return {
128
+ type: "paragraph",
129
+ content: [{ type: "text", text: `— ${name}` }],
130
+ };
131
+ }
132
+ return {
133
+ type: "paragraph",
134
+ content: [{ type: "text", text: "— " }, linked(href)],
135
+ };
136
+ }
137
+
138
+ function makeBlockquote(
139
+ quoteText: string,
140
+ attribution: JSONContent | null,
141
+ ): JSONContent {
142
+ const paragraphs = quoteText.split("\n").map((line) => makeParagraph(line));
143
+ if (attribution) paragraphs.push(attribution);
144
+ return { type: "blockquote", content: paragraphs };
145
+ }
146
+
147
+ /**
148
+ * Convert compose fields from one post format to another.
149
+ *
150
+ * @param from - the current format
151
+ * @param to - the target format
152
+ * @param fields - the current field values (not mutated)
153
+ * @returns new field values appropriate for the target format
154
+ * @example
155
+ * // quote → note: the quote becomes a leading blockquote in the body
156
+ * convertComposeFormat("quote", "note", {
157
+ * title: "", url: "", quoteText: "Stay hungry", quoteAuthor: "Jobs",
158
+ * showTitle: false, bodyJson: null,
159
+ * });
160
+ */
161
+ export function convertComposeFormat(
162
+ from: ComposeFormat,
163
+ to: ComposeFormat,
164
+ fields: ComposeConvertFields,
165
+ ): ComposeConvertFields {
166
+ if (from === to) return fields;
167
+
168
+ const out: ComposeConvertFields = { ...fields };
169
+ const body = cloneDoc(fields.bodyJson);
170
+ const content: JSONContent[] = Array.isArray(body?.content)
171
+ ? [...body.content]
172
+ : [];
173
+
174
+ // ── Extract: leading bare-link paragraph → url (reverses the link→note
175
+ // fold so link↔note round-trips). Only a "bare" link (text === href) is
176
+ // pulled out, so a labeled link line keeps its label instead of silently
177
+ // losing it. ─────────────────────────────────────────────────────────
178
+ if (to === "link" && out.url === "") {
179
+ const first = content[0];
180
+ const href = findLinkHref(first);
181
+ if (
182
+ href &&
183
+ first?.type === "paragraph" &&
184
+ Array.isArray(first.content) &&
185
+ first.content.length === 1 &&
186
+ nodeText(first) === href
187
+ ) {
188
+ out.url = href;
189
+ content.shift();
190
+ }
191
+ }
192
+
193
+ // ── Extract (focused: blockquote → quoteText only) ──────────────────
194
+ if (
195
+ to === "quote" &&
196
+ out.quoteText === "" &&
197
+ content[0]?.type === "blockquote"
198
+ ) {
199
+ const paragraphs = Array.isArray(content[0].content)
200
+ ? [...content[0].content]
201
+ : [];
202
+ const last = paragraphs[paragraphs.length - 1];
203
+ const lastText = nodeText(last);
204
+ if (paragraphs.length > 0 && /^—/.test(lastText.trim())) {
205
+ const parsed = parseAttribution(lastText);
206
+ // Prefer the link mark's href (the linked-author form) over a url parsed
207
+ // from plain text (legacy `— Author https://…`).
208
+ const url = findLinkHref(last) ?? parsed.url;
209
+ // When the attribution is url-only, the link text is the url itself —
210
+ // don't mistake it for an author.
211
+ const author = parsed.author === url ? "" : parsed.author;
212
+ if (author) out.quoteAuthor = out.quoteAuthor || author;
213
+ if (url && out.url === "") out.url = url;
214
+ paragraphs.pop();
215
+ }
216
+ out.quoteText = paragraphs.map(nodeText).join("\n").trim();
217
+ content.shift();
218
+ }
219
+
220
+ // ── Harvest (fold fields the target can't hold into the body) ───────
221
+ const prepend: JSONContent[] = [];
222
+
223
+ // Title → heading (only quote can't hold a title)
224
+ if (to === "quote" && out.title.trim() !== "") {
225
+ prepend.push(makeHeading(out.title.trim()));
226
+ out.title = "";
227
+ }
228
+
229
+ // quoteText → blockquote (note and link can't hold a quote)
230
+ if (to !== "quote" && out.quoteText.trim() !== "") {
231
+ // For a note, the source url has no home either, so fold it into the
232
+ // attribution. For a link, url maps to link.url and is preserved.
233
+ const foldUrl = to === "note" ? out.url : null;
234
+ prepend.push(
235
+ makeBlockquote(
236
+ out.quoteText,
237
+ makeAttributionParagraph(out.quoteAuthor, foldUrl),
238
+ ),
239
+ );
240
+ out.quoteText = "";
241
+ out.quoteAuthor = "";
242
+ if (to === "note") out.url = "";
243
+ } else if (to === "note" && out.url.trim() !== "") {
244
+ // Source was a link (no quote text): notes can't hold a url, so fold it.
245
+ prepend.push(makeLinkParagraph(out.url.trim()));
246
+ out.url = "";
247
+ }
248
+
249
+ if (prepend.length) content.unshift(...prepend);
250
+
251
+ out.bodyJson = content.length ? { type: "doc", content } : null;
252
+ out.showTitle = to === "note" && out.title.trim().length > 0;
253
+
254
+ return out;
255
+ }
@@ -214,6 +214,8 @@ export interface ComposeLabels {
214
214
  showMore: string;
215
215
  showLess: string;
216
216
  newThread: string;
217
+ replyTitle: string;
218
+ editTitle: string;
217
219
  slashHint: string;
218
220
  collectionFormLabels: CollectionFormLabels;
219
221
  }
@@ -36,6 +36,7 @@ import {
36
36
  getSelectedFirstOrder,
37
37
  } from "../collection-picker-order.js";
38
38
  import type { JantComposeEditor } from "./jant-compose-editor.js";
39
+ import { convertComposeFormat } from "./compose-format-convert.js";
39
40
  import { getMediaCategory } from "../../lib/upload.js";
40
41
  import { getSlugValidationIssue } from "../../lib/slug-format.js";
41
42
  import { createTiptapEditor } from "../tiptap/create-editor.js";
@@ -666,7 +667,7 @@ export class JantComposeDialog extends LitElement {
666
667
  super();
667
668
  this.collections = [];
668
669
  this.labels = {} as ComposeLabels;
669
- this.uploadMaxFileSize = 500;
670
+ this.uploadMaxFileSize = 1024;
670
671
  this.pageMode = false;
671
672
  this.closeHref = "/";
672
673
  this.autoRestoreDraft = false;
@@ -743,13 +744,14 @@ export class JantComposeDialog extends LitElement {
743
744
  this._scheduleCollectionPickerAutofocus();
744
745
  }
745
746
  if (
746
- changed.has("_format") ||
747
747
  changed.has("_collectionIds") ||
748
748
  changed.has("_slug") ||
749
749
  changed.has("_publishedAtInput") ||
750
750
  changed.has("_visibility")
751
751
  ) {
752
- // Schedule draft auto-save (new-post and edit modes, not draft-load)
752
+ // Schedule draft auto-save (new-post and edit modes, not draft-load).
753
+ // `_format` is intentionally excluded: a bare format switch is exploratory
754
+ // and shouldn't persist a draft on its own (see `_switchFormat`).
753
755
  if (!this._draftSourceId) {
754
756
  this._scheduleDraftSave();
755
757
  }
@@ -2456,7 +2458,7 @@ export class JantComposeDialog extends LitElement {
2456
2458
  "jant-compose-editor",
2457
2459
  )[this._focusedThreadIndex];
2458
2460
  editor?.dispatchEvent(
2459
- new CustomEvent("jant:thread-format-change", {
2461
+ new CustomEvent("jant:format-change", {
2460
2462
  detail: { format: target },
2461
2463
  bubbles: true,
2462
2464
  }),
@@ -3052,6 +3054,14 @@ export class JantComposeDialog extends LitElement {
3052
3054
  const editor = this._editor;
3053
3055
  if (!editor) return;
3054
3056
 
3057
+ // Only persist genuine unsaved changes. Without this, merely opening a post
3058
+ // for edit (or restoring a draft) would write a local draft of the
3059
+ // unchanged content, since loading fires content-change events.
3060
+ if (!this._hasUnsavedChanges()) {
3061
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3062
+ return;
3063
+ }
3064
+
3055
3065
  const data = editor.getData();
3056
3066
  const hasContent =
3057
3067
  !!data.body ||
@@ -3063,7 +3073,7 @@ export class JantComposeDialog extends LitElement {
3063
3073
  data.attachedTexts.length > 0;
3064
3074
 
3065
3075
  if (!hasContent) {
3066
- globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
3076
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3067
3077
  return;
3068
3078
  }
3069
3079
 
@@ -3098,7 +3108,7 @@ export class JantComposeDialog extends LitElement {
3098
3108
 
3099
3109
  try {
3100
3110
  globalThis.localStorage.setItem(
3101
- JantComposeDialog._DRAFT_KEY,
3111
+ this._currentDraftStorageKey(),
3102
3112
  JSON.stringify(draft),
3103
3113
  );
3104
3114
  } catch {
@@ -3549,10 +3559,43 @@ export class JantComposeDialog extends LitElement {
3549
3559
 
3550
3560
  private static readonly _FORMATS: ComposeFormat[] = ["note", "link", "quote"];
3551
3561
 
3562
+ /**
3563
+ * Whether a format switch should convert fields (fold/extract). Only when
3564
+ * editing an existing post or a server draft — for a brand-new post, switching
3565
+ * just hides/shows fields and nothing is persisted yet, so conversion would
3566
+ * pollute the body for no benefit.
3567
+ */
3568
+ private _shouldConvertOnFormatSwitch(): boolean {
3569
+ return !!(this._editPostId || this._draftSourceId);
3570
+ }
3571
+
3552
3572
  private _switchFormat(target: ComposeFormat) {
3553
3573
  if (this._format === target) return;
3554
- if (this._editPostId) return;
3574
+ const editor = this._editor;
3575
+ if (editor && this._shouldConvertOnFormatSwitch()) {
3576
+ // Fold fields the target can't hold into the body before the format
3577
+ // change recreates the editor from `_bodyJson`. Synchronous, so the old
3578
+ // Tiptap instance can't fire onUpdate and clobber what we just wrote.
3579
+ // `applyConvertedFields` suppresses the one content-change event the
3580
+ // conversion emits, so the switch itself never schedules a draft save.
3581
+ editor.applyConvertedFields(
3582
+ convertComposeFormat(
3583
+ this._format,
3584
+ target,
3585
+ editor.getConvertibleFields(),
3586
+ ),
3587
+ );
3588
+ }
3589
+ // A bare format switch shouldn't persist a local draft, so drop any save
3590
+ // already pending from loading the post.
3591
+ this._cancelDraftSaveTimer();
3555
3592
  this._format = target;
3593
+ // Sync the editor's format this tick. Lit commits the `.format` binding
3594
+ // only after this render returns, so `_canPublish()` (which reads
3595
+ // `editor.getData()`, keyed on the editor's format) would otherwise compute
3596
+ // against the stale format for one render and leave the submit button
3597
+ // wrongly disabled.
3598
+ if (editor) editor.format = target;
3556
3599
  this._showPublishPanel = false;
3557
3600
  if (this._shouldAutofocusFormatInput()) {
3558
3601
  globalThis.requestAnimationFrame(() => this._editor?.focusInput());
@@ -3571,6 +3614,16 @@ export class JantComposeDialog extends LitElement {
3571
3614
  const draftButtonLabel = this._hasContent()
3572
3615
  ? this.labels.saveAsDraft
3573
3616
  : this.labels.drafts;
3617
+ // Format selector sits inline (above each post) whenever more than one post
3618
+ // is on screen — a reply (parent shown above) or a multi-post thread. The
3619
+ // header then shows a plain title instead of the format selector.
3620
+ const isReply = !!(this._replyToId && this._replyToData);
3621
+ const showTitle = isReply || this._threadItems.length > 0;
3622
+ const headerTitle = this._editPostId
3623
+ ? this.labels.editTitle
3624
+ : isReply
3625
+ ? this.labels.replyTitle
3626
+ : this.labels.newThread;
3574
3627
 
3575
3628
  return html`
3576
3629
  <header class="compose-dialog-header">
@@ -3583,39 +3636,33 @@ export class JantComposeDialog extends LitElement {
3583
3636
  </button>
3584
3637
 
3585
3638
  <div class="compose-dialog-header-center">
3586
- ${this._editPostId
3587
- ? html`<span class="compose-dialog-title"
3588
- >${this.labels.editPost}</span
3589
- >`
3590
- : this._threadItems.length > 0
3591
- ? html`<span class="compose-dialog-title"
3592
- >${this.labels.newThread}</span
3593
- >`
3594
- : html`
3595
- <div class="compose-segmented">
3596
- <div
3597
- class=${classMap({
3598
- "compose-format-pill": true,
3599
- "compose-format-pill-link": this._format === "link",
3600
- "compose-format-pill-quote": this._format === "quote",
3601
- })}
3602
- ></div>
3603
- ${formats.map(
3604
- (f) => html`
3605
- <button
3606
- type="button"
3607
- class=${classMap({
3608
- "compose-segmented-item": true,
3609
- "compose-segmented-item-active": this._format === f,
3610
- })}
3611
- @click=${() => this._switchFormat(f)}
3612
- >
3613
- ${formatLabels[f]}
3614
- </button>
3615
- `,
3616
- )}
3617
- </div>
3618
- `}
3639
+ ${showTitle
3640
+ ? html`<span class="compose-dialog-title">${headerTitle}</span>`
3641
+ : html`
3642
+ <div class="compose-segmented">
3643
+ <div
3644
+ class=${classMap({
3645
+ "compose-format-pill": true,
3646
+ "compose-format-pill-link": this._format === "link",
3647
+ "compose-format-pill-quote": this._format === "quote",
3648
+ })}
3649
+ ></div>
3650
+ ${formats.map(
3651
+ (f) => html`
3652
+ <button
3653
+ type="button"
3654
+ class=${classMap({
3655
+ "compose-segmented-item": true,
3656
+ "compose-segmented-item-active": this._format === f,
3657
+ })}
3658
+ @click=${() => this._switchFormat(f)}
3659
+ >
3660
+ ${formatLabels[f]}
3661
+ </button>
3662
+ `,
3663
+ )}
3664
+ </div>
3665
+ `}
3619
3666
  </div>
3620
3667
 
3621
3668
  <div class="compose-dialog-header-actions">
@@ -5297,9 +5344,7 @@ export class JantComposeDialog extends LitElement {
5297
5344
  @focusin=${() => {
5298
5345
  this._focusedThreadIndex = index;
5299
5346
  }}
5300
- @jant:thread-format-change=${(
5301
- e: CustomEvent<{ format: ComposeFormat }>,
5302
- ) => {
5347
+ @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
5303
5348
  e.stopPropagation();
5304
5349
  this._threadItems = this._threadItems.map((it, i) =>
5305
5350
  i === index ? { ...it, format: e.detail.format } : it,
@@ -5423,6 +5468,7 @@ export class JantComposeDialog extends LitElement {
5423
5468
  .format=${this._format}
5424
5469
  .labels=${this.labels}
5425
5470
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5471
+ .inlineFormat=${isReply}
5426
5472
  .slashCommandDiscovered=${this.slashCommandDiscovered}
5427
5473
  ></jant-compose-editor>`;
5428
5474
 
@@ -5446,7 +5492,15 @@ export class JantComposeDialog extends LitElement {
5446
5492
  ? html`
5447
5493
  <div class="compose-thread-layout">
5448
5494
  ${this._renderReplyContext()}
5449
- <div class="compose-editor-row">
5495
+ <div
5496
+ class="compose-editor-row"
5497
+ @jant:format-change=${(
5498
+ e: CustomEvent<{ format: ComposeFormat }>,
5499
+ ) => {
5500
+ e.stopPropagation();
5501
+ this._switchFormat(e.detail.format);
5502
+ }}
5503
+ >
5450
5504
  <div class="compose-thread-dot"></div>
5451
5505
  ${editor}
5452
5506
  </div>
@@ -30,6 +30,7 @@ import type {
30
30
  ComposeEditorSelection,
31
31
  ComposeFullscreenOpenDetail,
32
32
  } from "./compose-types.js";
33
+ import type { ComposeConvertFields } from "./compose-format-convert.js";
33
34
  import {
34
35
  UPLOAD_ACCEPT,
35
36
  getMediaCategory,
@@ -178,6 +179,7 @@ export class JantComposeEditor extends LitElement {
178
179
  uploadMaxFileSize: { type: Number },
179
180
  threadItem: { type: Boolean, attribute: "thread-item" },
180
181
  removable: { type: Boolean },
182
+ inlineFormat: { type: Boolean, attribute: "inline-format" },
181
183
  slashCommandDiscovered: { type: Boolean },
182
184
  _title: { state: true },
183
185
  _bodyJson: { state: true },
@@ -203,6 +205,7 @@ export class JantComposeEditor extends LitElement {
203
205
  declare uploadMaxFileSize: number;
204
206
  declare threadItem: boolean;
205
207
  declare removable: boolean;
208
+ declare inlineFormat: boolean;
206
209
  declare slashCommandDiscovered: boolean;
207
210
  declare _title: string;
208
211
  declare _bodyJson: JSONContent | null;
@@ -234,6 +237,14 @@ export class JantComposeEditor extends LitElement {
234
237
  private _scrollBufferApplied = false;
235
238
  private _filePickerCleanup: (() => void) | null = null;
236
239
  private _suppressAttachedTextOpenUntil = 0;
240
+ /**
241
+ * Set by {@link applyConvertedFields} so the format-conversion content writes
242
+ * don't emit a content-changed event (which would schedule a draft autosave).
243
+ * A bare format switch shouldn't persist a local draft — see `_switchFormat`.
244
+ * Always consumed: a switch also changes `format`, so `updated()` is guaranteed
245
+ * to run this cycle.
246
+ */
247
+ private _suppressContentChangedOnce = false;
237
248
  #inlineImageUploadGeneration = 0;
238
249
  #inlineImageUploadPromises = new Set<Promise<void>>();
239
250
  #sortable: { destroy(): void } | null = null;
@@ -247,9 +258,10 @@ export class JantComposeEditor extends LitElement {
247
258
  super();
248
259
  this.format = "note";
249
260
  this.labels = {} as ComposeLabels;
250
- this.uploadMaxFileSize = 500;
261
+ this.uploadMaxFileSize = 1024;
251
262
  this.threadItem = false;
252
263
  this.removable = false;
264
+ this.inlineFormat = false;
253
265
  this.slashCommandDiscovered = false;
254
266
  this._title = "";
255
267
  this._bodyJson = null;
@@ -903,13 +915,19 @@ export class JantComposeEditor extends LitElement {
903
915
  }
904
916
  }
905
917
 
906
- // Notify parent dialog of content changes for draft auto-save
907
- for (const key of changed.keys()) {
908
- if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
909
- this.dispatchEvent(
910
- new Event("jant:compose-content-changed", { bubbles: true }),
911
- );
912
- break;
918
+ // Notify parent dialog of content changes for draft auto-save. A format
919
+ // conversion writes content fields too, but it's not a user edit, so skip
920
+ // the notification once when asked.
921
+ if (this._suppressContentChangedOnce) {
922
+ this._suppressContentChangedOnce = false;
923
+ } else {
924
+ for (const key of changed.keys()) {
925
+ if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
926
+ this.dispatchEvent(
927
+ new Event("jant:compose-content-changed", { bubbles: true }),
928
+ );
929
+ break;
930
+ }
913
931
  }
914
932
  }
915
933
  }
@@ -924,6 +942,39 @@ export class JantComposeEditor extends LitElement {
924
942
  };
925
943
  }
926
944
 
945
+ /**
946
+ * Raw field values for format conversion. Unlike {@link getData}, this returns
947
+ * every field regardless of the current format (so a hidden quote/url survives
948
+ * a switch) plus the freshest, normalized body.
949
+ */
950
+ getConvertibleFields(): ComposeConvertFields {
951
+ return {
952
+ title: this._title,
953
+ url: this._url,
954
+ quoteText: this._quoteText,
955
+ quoteAuthor: this._quoteAuthor,
956
+ showTitle: this._showTitle,
957
+ bodyJson: this._normalizeDocJson(
958
+ this._editor?.getJSON() ?? this._bodyJson,
959
+ ),
960
+ };
961
+ }
962
+
963
+ /**
964
+ * Write back fields produced by `convertComposeFormat`. The body is applied via
965
+ * `_bodyJson` only — the imminent format change recreates the editor from it, so
966
+ * calling `setContent` here would be redundant.
967
+ */
968
+ applyConvertedFields(fields: ComposeConvertFields): void {
969
+ this._suppressContentChangedOnce = true;
970
+ this._title = fields.title;
971
+ this._url = fields.url;
972
+ this._quoteText = fields.quoteText;
973
+ this._quoteAuthor = fields.quoteAuthor;
974
+ this._showTitle = fields.showTitle;
975
+ this._bodyJson = fields.bodyJson;
976
+ }
977
+
927
978
  /** Pre-fill all fields for edit mode or draft restore */
928
979
  populate(data: {
929
980
  format: string;
@@ -2433,7 +2484,7 @@ export class JantComposeEditor extends LitElement {
2433
2484
  `;
2434
2485
  }
2435
2486
 
2436
- private _renderThreadPostHeader() {
2487
+ private _renderFormatHeader() {
2437
2488
  const formatLabels: Record<ComposeFormat, string> = {
2438
2489
  note: this.labels.note,
2439
2490
  link: this.labels.link,
@@ -2463,7 +2514,7 @@ export class JantComposeEditor extends LitElement {
2463
2514
  @click=${() => {
2464
2515
  if (this.format !== f) {
2465
2516
  this.dispatchEvent(
2466
- new CustomEvent("jant:thread-format-change", {
2517
+ new CustomEvent("jant:format-change", {
2467
2518
  bubbles: true,
2468
2519
  detail: { format: f },
2469
2520
  }),
@@ -2518,7 +2569,9 @@ export class JantComposeEditor extends LitElement {
2518
2569
 
2519
2570
  render() {
2520
2571
  return html`
2521
- ${this.threadItem ? this._renderThreadPostHeader() : nothing}
2572
+ ${this.threadItem || this.inlineFormat
2573
+ ? this._renderFormatHeader()
2574
+ : nothing}
2522
2575
  <section class="compose-body">
2523
2576
  ${this.format === "note"
2524
2577
  ? this._renderNoteFields()