@jant/core 0.6.7 → 0.6.9
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/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { JSONContent } from "@tiptap/core";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
convertComposeFormat,
|
|
6
|
+
type ComposeConvertFields,
|
|
7
|
+
} from "../compose-format-convert.js";
|
|
8
|
+
|
|
9
|
+
const EMPTY: ComposeConvertFields = {
|
|
10
|
+
title: "",
|
|
11
|
+
url: "",
|
|
12
|
+
quoteText: "",
|
|
13
|
+
quoteAuthor: "",
|
|
14
|
+
showTitle: false,
|
|
15
|
+
bodyJson: null,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function fields(
|
|
19
|
+
overrides: Partial<ComposeConvertFields>,
|
|
20
|
+
): ComposeConvertFields {
|
|
21
|
+
return { ...EMPTY, ...overrides };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function doc(...content: JSONContent[]): JSONContent {
|
|
25
|
+
return { type: "doc", content };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paragraph(text: string): JSONContent {
|
|
29
|
+
return { type: "paragraph", content: [{ type: "text", text }] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function linkParagraph(url: string, text = url): JSONContent {
|
|
33
|
+
return {
|
|
34
|
+
type: "paragraph",
|
|
35
|
+
content: [
|
|
36
|
+
{ type: "text", text, marks: [{ type: "link", attrs: { href: url } }] },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** First block of the resulting body. */
|
|
42
|
+
function firstBlock(result: ComposeConvertFields): JSONContent | undefined {
|
|
43
|
+
return result.bodyJson?.content?.[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("convertComposeFormat", () => {
|
|
47
|
+
it("returns input unchanged when from === to", () => {
|
|
48
|
+
const input = fields({ quoteText: "x" });
|
|
49
|
+
expect(convertComposeFormat("quote", "quote", input)).toBe(input);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("note → quote folds a title into a leading heading", () => {
|
|
53
|
+
const result = convertComposeFormat(
|
|
54
|
+
"note",
|
|
55
|
+
"quote",
|
|
56
|
+
fields({
|
|
57
|
+
title: "My thoughts",
|
|
58
|
+
showTitle: true,
|
|
59
|
+
bodyJson: doc(paragraph("body")),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(result.title).toBe("");
|
|
63
|
+
expect(firstBlock(result)).toEqual({
|
|
64
|
+
type: "heading",
|
|
65
|
+
attrs: { level: 2 },
|
|
66
|
+
content: [{ type: "text", text: "My thoughts" }],
|
|
67
|
+
});
|
|
68
|
+
expect(result.bodyJson?.content?.[1]).toEqual(paragraph("body"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("quote → note folds the quote into a leading blockquote with attribution", () => {
|
|
72
|
+
const result = convertComposeFormat(
|
|
73
|
+
"quote",
|
|
74
|
+
"note",
|
|
75
|
+
fields({ quoteText: "Stay hungry", quoteAuthor: "Jobs" }),
|
|
76
|
+
);
|
|
77
|
+
expect(result.quoteText).toBe("");
|
|
78
|
+
expect(result.quoteAuthor).toBe("");
|
|
79
|
+
const bq = firstBlock(result);
|
|
80
|
+
expect(bq?.type).toBe("blockquote");
|
|
81
|
+
expect(bq?.content?.[0]).toEqual(paragraph("Stay hungry"));
|
|
82
|
+
expect(bq?.content?.[1]).toEqual(paragraph("— Jobs"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("quote → note links the author to the source url and clears url", () => {
|
|
86
|
+
const result = convertComposeFormat(
|
|
87
|
+
"quote",
|
|
88
|
+
"note",
|
|
89
|
+
fields({
|
|
90
|
+
quoteText: "Be water",
|
|
91
|
+
quoteAuthor: "Lee",
|
|
92
|
+
url: "https://example.com",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(result.url).toBe("");
|
|
96
|
+
const bq = firstBlock(result);
|
|
97
|
+
expect(bq?.content?.[1]).toEqual({
|
|
98
|
+
type: "paragraph",
|
|
99
|
+
content: [
|
|
100
|
+
{ type: "text", text: "— " },
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: "Lee",
|
|
104
|
+
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("quote → note with a url but no author links the url itself", () => {
|
|
111
|
+
const result = convertComposeFormat(
|
|
112
|
+
"quote",
|
|
113
|
+
"note",
|
|
114
|
+
fields({ quoteText: "Anon", url: "https://example.com" }),
|
|
115
|
+
);
|
|
116
|
+
const bq = firstBlock(result);
|
|
117
|
+
expect(bq?.content?.[1]).toEqual({
|
|
118
|
+
type: "paragraph",
|
|
119
|
+
content: [
|
|
120
|
+
{ type: "text", text: "— " },
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: "https://example.com",
|
|
124
|
+
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("note → quote recovers a url-only attribution without a phantom author", () => {
|
|
131
|
+
const note = convertComposeFormat(
|
|
132
|
+
"quote",
|
|
133
|
+
"note",
|
|
134
|
+
fields({ quoteText: "Anon", url: "https://example.com" }),
|
|
135
|
+
);
|
|
136
|
+
const back = convertComposeFormat("note", "quote", note);
|
|
137
|
+
expect(back.quoteText).toBe("Anon");
|
|
138
|
+
expect(back.quoteAuthor).toBe("");
|
|
139
|
+
expect(back.url).toBe("https://example.com");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("note → quote still parses the legacy plain-text attribution form", () => {
|
|
143
|
+
const result = convertComposeFormat(
|
|
144
|
+
"note",
|
|
145
|
+
"quote",
|
|
146
|
+
fields({
|
|
147
|
+
bodyJson: doc({
|
|
148
|
+
type: "blockquote",
|
|
149
|
+
content: [
|
|
150
|
+
paragraph("Be water"),
|
|
151
|
+
paragraph("— Lee https://example.com"),
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
expect(result.quoteText).toBe("Be water");
|
|
157
|
+
expect(result.quoteAuthor).toBe("Lee");
|
|
158
|
+
expect(result.url).toBe("https://example.com");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("quote → note → quote round-trips quote text and author", () => {
|
|
162
|
+
const start = fields({ quoteText: "Stay hungry", quoteAuthor: "Jobs" });
|
|
163
|
+
const note = convertComposeFormat("quote", "note", start);
|
|
164
|
+
const back = convertComposeFormat("note", "quote", note);
|
|
165
|
+
expect(back.quoteText).toBe("Stay hungry");
|
|
166
|
+
expect(back.quoteAuthor).toBe("Jobs");
|
|
167
|
+
expect(back.bodyJson).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("quote → note → quote round-trips the source url too", () => {
|
|
171
|
+
const start = fields({
|
|
172
|
+
quoteText: "Be water",
|
|
173
|
+
quoteAuthor: "Lee",
|
|
174
|
+
url: "https://example.com",
|
|
175
|
+
});
|
|
176
|
+
const note = convertComposeFormat("quote", "note", start);
|
|
177
|
+
const back = convertComposeFormat("note", "quote", note);
|
|
178
|
+
expect(back.quoteText).toBe("Be water");
|
|
179
|
+
expect(back.quoteAuthor).toBe("Lee");
|
|
180
|
+
expect(back.url).toBe("https://example.com");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("link → quote preserves the url and folds a title into a heading", () => {
|
|
184
|
+
const result = convertComposeFormat(
|
|
185
|
+
"link",
|
|
186
|
+
"quote",
|
|
187
|
+
fields({ title: "Cool link", url: "https://example.com" }),
|
|
188
|
+
);
|
|
189
|
+
expect(result.url).toBe("https://example.com");
|
|
190
|
+
expect(result.title).toBe("");
|
|
191
|
+
expect(firstBlock(result)?.type).toBe("heading");
|
|
192
|
+
expect(result.quoteText).toBe("");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("quote → link preserves the url and folds the quote, leaving no title", () => {
|
|
196
|
+
const result = convertComposeFormat(
|
|
197
|
+
"quote",
|
|
198
|
+
"link",
|
|
199
|
+
fields({
|
|
200
|
+
quoteText: "Quoted",
|
|
201
|
+
quoteAuthor: "Author",
|
|
202
|
+
url: "https://example.com",
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
expect(result.url).toBe("https://example.com");
|
|
206
|
+
expect(result.title).toBe("");
|
|
207
|
+
expect(result.quoteText).toBe("");
|
|
208
|
+
const bq = firstBlock(result);
|
|
209
|
+
expect(bq?.type).toBe("blockquote");
|
|
210
|
+
expect(bq?.content?.[1]).toEqual(paragraph("— Author"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("note → link preserves the title and leaves the url empty", () => {
|
|
214
|
+
const result = convertComposeFormat(
|
|
215
|
+
"note",
|
|
216
|
+
"link",
|
|
217
|
+
fields({
|
|
218
|
+
title: "Keep me",
|
|
219
|
+
showTitle: true,
|
|
220
|
+
bodyJson: doc(paragraph("body")),
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
expect(result.title).toBe("Keep me");
|
|
224
|
+
expect(result.url).toBe("");
|
|
225
|
+
expect(result.bodyJson).toEqual(doc(paragraph("body")));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("link → note preserves the title and folds the url into a link paragraph", () => {
|
|
229
|
+
const result = convertComposeFormat(
|
|
230
|
+
"link",
|
|
231
|
+
"note",
|
|
232
|
+
fields({
|
|
233
|
+
title: "Keep me",
|
|
234
|
+
url: "https://example.com",
|
|
235
|
+
bodyJson: doc(paragraph("body")),
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
expect(result.title).toBe("Keep me");
|
|
239
|
+
expect(result.showTitle).toBe(true);
|
|
240
|
+
expect(result.url).toBe("");
|
|
241
|
+
expect(firstBlock(result)).toEqual({
|
|
242
|
+
type: "paragraph",
|
|
243
|
+
content: [
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: "https://example.com",
|
|
247
|
+
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("note → link extracts a leading bare-link paragraph into the url", () => {
|
|
254
|
+
const result = convertComposeFormat(
|
|
255
|
+
"note",
|
|
256
|
+
"link",
|
|
257
|
+
fields({
|
|
258
|
+
title: "T",
|
|
259
|
+
bodyJson: doc(linkParagraph("https://example.com"), paragraph("body")),
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
expect(result.url).toBe("https://example.com");
|
|
263
|
+
expect(result.bodyJson).toEqual(doc(paragraph("body")));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("link → note → link round-trips the url, title, and body", () => {
|
|
267
|
+
const start = fields({
|
|
268
|
+
title: "Keep me",
|
|
269
|
+
url: "https://example.com",
|
|
270
|
+
bodyJson: doc(paragraph("body")),
|
|
271
|
+
});
|
|
272
|
+
const note = convertComposeFormat("link", "note", start);
|
|
273
|
+
const back = convertComposeFormat("note", "link", note);
|
|
274
|
+
expect(back.title).toBe("Keep me");
|
|
275
|
+
expect(back.url).toBe("https://example.com");
|
|
276
|
+
expect(back.bodyJson).toEqual(doc(paragraph("body")));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("note → link leaves a labeled-link first line in the body", () => {
|
|
280
|
+
const result = convertComposeFormat(
|
|
281
|
+
"note",
|
|
282
|
+
"link",
|
|
283
|
+
fields({
|
|
284
|
+
title: "T",
|
|
285
|
+
bodyJson: doc(
|
|
286
|
+
linkParagraph("https://example.com", "Read this"),
|
|
287
|
+
paragraph("body"),
|
|
288
|
+
),
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
// A labeled link isn't a bare url — keep it in the body, leave url empty.
|
|
292
|
+
expect(result.url).toBe("");
|
|
293
|
+
expect(result.bodyJson).toEqual(
|
|
294
|
+
doc(linkParagraph("https://example.com", "Read this"), paragraph("body")),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("note → quote with an empty body and no fields yields a null body", () => {
|
|
299
|
+
const result = convertComposeFormat("note", "quote", fields({}));
|
|
300
|
+
expect(result.bodyJson).toBeNull();
|
|
301
|
+
expect(result.quoteText).toBe("");
|
|
302
|
+
expect(result.title).toBe("");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("note → quote extracts a body that is only a blockquote, leaving the body null", () => {
|
|
306
|
+
const result = convertComposeFormat(
|
|
307
|
+
"note",
|
|
308
|
+
"quote",
|
|
309
|
+
fields({
|
|
310
|
+
bodyJson: doc({
|
|
311
|
+
type: "blockquote",
|
|
312
|
+
content: [paragraph("Quoted line")],
|
|
313
|
+
}),
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
expect(result.quoteText).toBe("Quoted line");
|
|
317
|
+
expect(result.bodyJson).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("sets showTitle only for a note that ends up with a title", () => {
|
|
321
|
+
const toNote = convertComposeFormat(
|
|
322
|
+
"link",
|
|
323
|
+
"note",
|
|
324
|
+
fields({ title: "T", url: "https://example.com" }),
|
|
325
|
+
);
|
|
326
|
+
expect(toNote.showTitle).toBe(true);
|
|
327
|
+
|
|
328
|
+
const toQuote = convertComposeFormat(
|
|
329
|
+
"note",
|
|
330
|
+
"quote",
|
|
331
|
+
fields({ title: "T" }),
|
|
332
|
+
);
|
|
333
|
+
expect(toQuote.showTitle).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("treats a non-attribution last line as part of the quote text", () => {
|
|
337
|
+
const result = convertComposeFormat(
|
|
338
|
+
"note",
|
|
339
|
+
"quote",
|
|
340
|
+
fields({
|
|
341
|
+
bodyJson: doc({
|
|
342
|
+
type: "blockquote",
|
|
343
|
+
content: [paragraph("Line one"), paragraph("Line two")],
|
|
344
|
+
}),
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
expect(result.quoteText).toBe("Line one\nLine two");
|
|
348
|
+
expect(result.quoteAuthor).toBe("");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("does not mutate the input bodyJson", () => {
|
|
352
|
+
const bodyJson = doc(paragraph("body"));
|
|
353
|
+
const snapshot = JSON.stringify(bodyJson);
|
|
354
|
+
convertComposeFormat("quote", "note", fields({ quoteText: "q", bodyJson }));
|
|
355
|
+
expect(JSON.stringify(bodyJson)).toBe(snapshot);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -230,6 +230,8 @@ const labels: ComposeLabels = {
|
|
|
230
230
|
showMore: "Show more",
|
|
231
231
|
showLess: "Show less",
|
|
232
232
|
newThread: "New Thread",
|
|
233
|
+
replyTitle: "Reply",
|
|
234
|
+
editTitle: "Edit",
|
|
233
235
|
slashHint: "Type / for commands",
|
|
234
236
|
collectionFormLabels: {
|
|
235
237
|
titleLabel: "Title",
|
|
@@ -601,6 +603,317 @@ describe("JantComposeDialog", () => {
|
|
|
601
603
|
expect(focusSpy).not.toHaveBeenCalled();
|
|
602
604
|
});
|
|
603
605
|
|
|
606
|
+
function mockEditPost(post: Record<string, unknown>) {
|
|
607
|
+
return vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
608
|
+
new Response(JSON.stringify({ id: "pst_123", ...post }), {
|
|
609
|
+
headers: { "Content-Type": "application/json" },
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("shows the format switcher instead of a title while editing", async () => {
|
|
615
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
616
|
+
cb(0);
|
|
617
|
+
return 1;
|
|
618
|
+
});
|
|
619
|
+
mockEditPost({ format: "note", title: "Hello", body: null });
|
|
620
|
+
|
|
621
|
+
const el = await createElement();
|
|
622
|
+
await el.openEdit("pst_123");
|
|
623
|
+
await flushUpdates(el);
|
|
624
|
+
|
|
625
|
+
expect(el.querySelector(".compose-segmented")).not.toBeNull();
|
|
626
|
+
// The "Edit post" title is gone — the switcher takes the center slot.
|
|
627
|
+
expect(el.querySelector(".compose-dialog-title")).toBeNull();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("shows a Reply title with the format selector above the post when replying", async () => {
|
|
631
|
+
const el = await createElement();
|
|
632
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
633
|
+
contentHtml: "<p>Parent</p>",
|
|
634
|
+
dateText: "Mar 14",
|
|
635
|
+
});
|
|
636
|
+
await flushUpdates(el);
|
|
637
|
+
|
|
638
|
+
expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
|
|
639
|
+
"Reply",
|
|
640
|
+
);
|
|
641
|
+
// The header center no longer hosts the format selector...
|
|
642
|
+
expect(
|
|
643
|
+
el.querySelector(".compose-dialog-header-center .compose-segmented"),
|
|
644
|
+
).toBeNull();
|
|
645
|
+
// ...it sits inline above the reply editor instead.
|
|
646
|
+
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("shows an Edit title with the format selector above the post when editing a reply", async () => {
|
|
650
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
651
|
+
cb(0);
|
|
652
|
+
return 1;
|
|
653
|
+
});
|
|
654
|
+
const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
|
|
655
|
+
// A fresh Response per call: openEdit reads the edited post, then
|
|
656
|
+
// _fetchReplyContext reads the parent — a single shared Response body can
|
|
657
|
+
// only be consumed once.
|
|
658
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
|
659
|
+
const url = String(input);
|
|
660
|
+
const json = url.includes(parentId)
|
|
661
|
+
? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
|
|
662
|
+
: {
|
|
663
|
+
id: "pst_123",
|
|
664
|
+
format: "note",
|
|
665
|
+
title: "Hello",
|
|
666
|
+
body: null,
|
|
667
|
+
replyToId: parentId,
|
|
668
|
+
};
|
|
669
|
+
return Promise.resolve(
|
|
670
|
+
new Response(JSON.stringify(json), {
|
|
671
|
+
headers: { "Content-Type": "application/json" },
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const el = await createElement();
|
|
677
|
+
await el.openEdit("pst_123");
|
|
678
|
+
await flushUpdates(el);
|
|
679
|
+
|
|
680
|
+
expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
|
|
681
|
+
"Edit",
|
|
682
|
+
);
|
|
683
|
+
expect(
|
|
684
|
+
el.querySelector(".compose-dialog-header-center .compose-segmented"),
|
|
685
|
+
).toBeNull();
|
|
686
|
+
expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("switches format from the inline selector when replying", async () => {
|
|
690
|
+
const el = await createElement();
|
|
691
|
+
await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
|
|
692
|
+
contentHtml: "<p>Parent</p>",
|
|
693
|
+
dateText: "Mar 14",
|
|
694
|
+
});
|
|
695
|
+
await flushUpdates(el);
|
|
696
|
+
|
|
697
|
+
const items = el.querySelectorAll<HTMLButtonElement>(
|
|
698
|
+
".compose-thread-post-header .compose-segmented-item",
|
|
699
|
+
);
|
|
700
|
+
// note / link / quote
|
|
701
|
+
expect(items.length).toBe(3);
|
|
702
|
+
items[1].click(); // link
|
|
703
|
+
await flushUpdates(el);
|
|
704
|
+
|
|
705
|
+
expect(el._format).toBe("link");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("edit-mode format switch folds quote fields into the body", async () => {
|
|
709
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
710
|
+
cb(0);
|
|
711
|
+
return 1;
|
|
712
|
+
});
|
|
713
|
+
mockEditPost({
|
|
714
|
+
format: "quote",
|
|
715
|
+
quoteText: "Stay hungry",
|
|
716
|
+
sourceName: "Jobs",
|
|
717
|
+
body: null,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const el = await createElement();
|
|
721
|
+
await el.openEdit("pst_123");
|
|
722
|
+
await flushUpdates(el);
|
|
723
|
+
|
|
724
|
+
const editor = requireElement(
|
|
725
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
726
|
+
"expected compose editor",
|
|
727
|
+
);
|
|
728
|
+
expect(editor._quoteText).toBe("Stay hungry");
|
|
729
|
+
|
|
730
|
+
// Click the Note segmented button.
|
|
731
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
732
|
+
".compose-segmented-item",
|
|
733
|
+
)[0].click();
|
|
734
|
+
await flushUpdates(el);
|
|
735
|
+
|
|
736
|
+
expect(el._format).toBe("note");
|
|
737
|
+
expect(editor._quoteText).toBe("");
|
|
738
|
+
expect(editor._quoteAuthor).toBe("");
|
|
739
|
+
expect(editor._bodyJson?.content?.[0]?.type).toBe("blockquote");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("edit-mode format switch marks the post as having unsaved changes", async () => {
|
|
743
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
744
|
+
cb(0);
|
|
745
|
+
return 1;
|
|
746
|
+
});
|
|
747
|
+
mockEditPost({ format: "note", title: "Hello", body: null });
|
|
748
|
+
|
|
749
|
+
const el = await createElement();
|
|
750
|
+
await el.openEdit("pst_123");
|
|
751
|
+
await flushUpdates(el);
|
|
752
|
+
|
|
753
|
+
const hasUnsaved = (el as unknown as { _hasUnsavedChanges(): boolean })
|
|
754
|
+
._hasUnsavedChanges;
|
|
755
|
+
expect(hasUnsaved.call(el)).toBe(false);
|
|
756
|
+
|
|
757
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
758
|
+
".compose-segmented-item",
|
|
759
|
+
)[2].click();
|
|
760
|
+
await flushUpdates(el);
|
|
761
|
+
|
|
762
|
+
expect(el._format).toBe("quote");
|
|
763
|
+
expect(hasUnsaved.call(el)).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("edit-mode autosave writes to the edit-specific draft key", async () => {
|
|
767
|
+
vi.useFakeTimers();
|
|
768
|
+
try {
|
|
769
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
770
|
+
cb(0);
|
|
771
|
+
return 1;
|
|
772
|
+
});
|
|
773
|
+
mockEditPost({ format: "note", title: "Hello", body: null });
|
|
774
|
+
|
|
775
|
+
const el = await createElement();
|
|
776
|
+
await el.openEdit("pst_123");
|
|
777
|
+
await el.updateComplete;
|
|
778
|
+
|
|
779
|
+
const editor = requireElement(
|
|
780
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
781
|
+
"expected compose editor",
|
|
782
|
+
);
|
|
783
|
+
editor._bodyJson = {
|
|
784
|
+
type: "doc",
|
|
785
|
+
content: [
|
|
786
|
+
{
|
|
787
|
+
type: "paragraph",
|
|
788
|
+
content: [{ type: "text", text: "Edited body" }],
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
};
|
|
792
|
+
await editor.updateComplete;
|
|
793
|
+
|
|
794
|
+
vi.advanceTimersByTime(1000);
|
|
795
|
+
|
|
796
|
+
expect(
|
|
797
|
+
globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
|
|
798
|
+
).not.toBeNull();
|
|
799
|
+
expect(globalThis.localStorage.getItem("jant:compose-draft")).toBeNull();
|
|
800
|
+
} finally {
|
|
801
|
+
vi.useRealTimers();
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("autosaves only on a real edit, not on open or a bare format switch", async () => {
|
|
806
|
+
vi.useFakeTimers();
|
|
807
|
+
try {
|
|
808
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
809
|
+
cb(0);
|
|
810
|
+
return 1;
|
|
811
|
+
});
|
|
812
|
+
mockEditPost({
|
|
813
|
+
format: "note",
|
|
814
|
+
title: null,
|
|
815
|
+
body: JSON.stringify({
|
|
816
|
+
type: "doc",
|
|
817
|
+
content: [
|
|
818
|
+
{ type: "paragraph", content: [{ type: "text", text: "hi" }] },
|
|
819
|
+
],
|
|
820
|
+
}),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
const el = await createElement();
|
|
824
|
+
await el.openEdit("pst_123");
|
|
825
|
+
await el.updateComplete;
|
|
826
|
+
const editor = requireElement(
|
|
827
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
828
|
+
"expected compose editor",
|
|
829
|
+
);
|
|
830
|
+
await editor.updateComplete;
|
|
831
|
+
|
|
832
|
+
// Opening a post for edit must not persist a local draft on its own.
|
|
833
|
+
vi.advanceTimersByTime(1000);
|
|
834
|
+
expect(
|
|
835
|
+
globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
|
|
836
|
+
).toBeNull();
|
|
837
|
+
|
|
838
|
+
// Switching format only must not persist either.
|
|
839
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
840
|
+
".compose-segmented-item",
|
|
841
|
+
)[2].click();
|
|
842
|
+
await el.updateComplete;
|
|
843
|
+
await editor.updateComplete;
|
|
844
|
+
vi.advanceTimersByTime(1000);
|
|
845
|
+
expect(
|
|
846
|
+
globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
|
|
847
|
+
).toBeNull();
|
|
848
|
+
|
|
849
|
+
// A real edit afterwards persists as usual, with the switched format.
|
|
850
|
+
editor._quoteText = "now editing";
|
|
851
|
+
await editor.updateComplete;
|
|
852
|
+
vi.advanceTimersByTime(1000);
|
|
853
|
+
|
|
854
|
+
const saved = globalThis.localStorage.getItem(
|
|
855
|
+
"jant:compose-edit:pst_123",
|
|
856
|
+
);
|
|
857
|
+
expect(saved).not.toBeNull();
|
|
858
|
+
expect(JSON.parse(saved as string).format).toBe("quote");
|
|
859
|
+
} finally {
|
|
860
|
+
vi.useRealTimers();
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("enables the publish button right after an edit-mode switch to quote", async () => {
|
|
865
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
866
|
+
cb(0);
|
|
867
|
+
return 1;
|
|
868
|
+
});
|
|
869
|
+
// A note whose body is a blockquote + paragraph — switching to quote
|
|
870
|
+
// extracts the blockquote into the quote-text field.
|
|
871
|
+
mockEditPost({
|
|
872
|
+
format: "note",
|
|
873
|
+
title: null,
|
|
874
|
+
body: JSON.stringify({
|
|
875
|
+
type: "doc",
|
|
876
|
+
content: [
|
|
877
|
+
{
|
|
878
|
+
type: "blockquote",
|
|
879
|
+
content: [
|
|
880
|
+
{
|
|
881
|
+
type: "paragraph",
|
|
882
|
+
content: [{ type: "text", text: "quote2233" }],
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
type: "paragraph",
|
|
886
|
+
content: [{ type: "text", text: "— author1" }],
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
},
|
|
890
|
+
{ type: "paragraph", content: [{ type: "text", text: "这个22" }] },
|
|
891
|
+
],
|
|
892
|
+
}),
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const el = await createElement();
|
|
896
|
+
await el.openEdit("pst_123");
|
|
897
|
+
await flushUpdates(el);
|
|
898
|
+
|
|
899
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
900
|
+
".compose-segmented-item",
|
|
901
|
+
)[2].click();
|
|
902
|
+
await flushUpdates(el);
|
|
903
|
+
|
|
904
|
+
const editor = requireElement(
|
|
905
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
906
|
+
"expected compose editor",
|
|
907
|
+
);
|
|
908
|
+
expect(el._format).toBe("quote");
|
|
909
|
+
expect(editor._quoteText).toBe("quote2233");
|
|
910
|
+
// The submit button must reflect the now-valid quote, not the stale
|
|
911
|
+
// pre-switch format.
|
|
912
|
+
expect(
|
|
913
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main")?.disabled,
|
|
914
|
+
).toBe(false);
|
|
915
|
+
});
|
|
916
|
+
|
|
604
917
|
it("submit dispatches jant:compose-submit-deferred with correct payload", async () => {
|
|
605
918
|
const el = await createElement();
|
|
606
919
|
const editor = requireElement(
|
|
@@ -264,6 +264,8 @@ const labels: ComposeLabels = {
|
|
|
264
264
|
showMore: "Show more",
|
|
265
265
|
showLess: "Show less",
|
|
266
266
|
newThread: "New Thread",
|
|
267
|
+
replyTitle: "Reply",
|
|
268
|
+
editTitle: "Edit",
|
|
267
269
|
slashHint: "Type / for commands",
|
|
268
270
|
collectionFormLabels: {
|
|
269
271
|
titleLabel: "Title",
|