@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.
- package/bin/commands/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- 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
|
|
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-
|
|
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
|
-
|
|
220
|
-
<path d="
|
|
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
|
-
<
|
|
227
|
-
<path d="
|
|
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
|
|
248
|
-
icon: (typeof
|
|
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-
|
|
252
|
-
width="
|
|
253
|
-
height="
|
|
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(
|
|
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({
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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:
|
|
1370
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1469
|
-
this.
|
|
1470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2050
|
-
this.
|
|
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
|
-
|
|
2061
|
-
|
|
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.
|
|
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.
|
|
2287
|
+
this._getPostSlug(index).trim() !== slug
|
|
2092
2288
|
) {
|
|
2093
2289
|
return;
|
|
2094
2290
|
}
|
|
2095
|
-
this.
|
|
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.
|
|
2119
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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
|
-
<
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
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
|
-
|
|
3695
|
-
</
|
|
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
|
-
|
|
3799
|
-
|
|
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.
|
|
3807
|
-
"compose-collection-
|
|
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
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
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
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
}
|
|
4687
|
+
const item = this._threadItems[index];
|
|
4688
|
+
const input = item?.publishedAtInput ?? "";
|
|
4689
|
+
if (!input.trim()) return undefined;
|
|
4426
4690
|
|
|
4427
|
-
|
|
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 =
|
|
4736
|
+
this._showPublishPanel = false;
|
|
4737
|
+
this._postMetaIndex = index;
|
|
4467
4738
|
this._confirmPanelOpen = false;
|
|
4468
4739
|
this._scheduleSuggestedSlugRefresh(true);
|
|
4469
|
-
this.
|
|
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
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
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.
|
|
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.
|
|
4568
|
-
this.
|
|
4807
|
+
this._setPostSlug(index, value.toLowerCase());
|
|
4808
|
+
this._setPostSlugTaken(index, false);
|
|
4569
4809
|
this._slugCheckLoading = false;
|
|
4570
|
-
if (this.
|
|
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
|
|
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.
|
|
4584
|
-
|
|
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
|
-
|
|
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-
|
|
4621
|
-
"compose-
|
|
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-
|
|
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
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
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
|
-
|
|
4646
|
-
|
|
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
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
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
|
-
${
|
|
4690
|
-
</
|
|
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
|
-
|
|
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
|
|
4699
|
-
const
|
|
4700
|
-
const
|
|
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
|
-
<
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
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
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
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
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
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
|
-
:
|
|
4803
|
-
|
|
4804
|
-
|
|
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.
|
|
5118
|
+
if (!this._canReplyQuietly()) return nothing;
|
|
4810
5119
|
|
|
4811
5120
|
return html`
|
|
4812
|
-
<
|
|
4813
|
-
<
|
|
4814
|
-
<
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
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
|
-
|
|
4851
|
-
|
|
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.
|
|
5553
|
+
${this._renderVisibilityRow(
|
|
4856
5554
|
"latest_hidden",
|
|
4857
5555
|
this.labels.publishVisibilityHiddenFromLatest,
|
|
5556
|
+
this.labels.publishVisibilityHiddenFromLatestHint,
|
|
4858
5557
|
)}
|
|
4859
|
-
${this.
|
|
5558
|
+
${this._renderVisibilityRow(
|
|
4860
5559
|
"private",
|
|
4861
5560
|
this.labels.publishVisibilityPrivate,
|
|
5561
|
+
this.labels.publishVisibilityPrivateHint,
|
|
4862
5562
|
)}
|
|
4863
5563
|
</div>
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
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.
|
|
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
|
|
5315
|
-
|
|
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
|
|
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
|
|
5452
|
-
const
|
|
5453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5515
|
-
|
|
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
|
-
|
|
5518
|
-
<
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
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
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
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-
|
|
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
|
-
|
|
5588
|
-
|
|
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
|
-
|
|
5591
|
-
`
|
|
5592
|
-
|
|
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
|