@jant/core 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/uploads/cleanup.js +1 -0
- package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- 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-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.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__/json.test.ts +94 -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 +357 -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/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +106 -44
- package/src/client/components/jant-compose-editor.ts +65 -11
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -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/json.ts +56 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/note-expand.ts +63 -0
- package/src/client/upload-session.ts +17 -9
- package/src/client.ts +1 -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 +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- 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/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -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/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +77 -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/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- 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 +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/site-admin.ts +121 -0
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -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/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/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-CL2PC1Fl.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-
|
|
2
|
-
import { r as getInstallationToken } from "./github-app-
|
|
3
|
-
import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-
|
|
1
|
+
import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Be082J0n.js";
|
|
2
|
+
import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
|
|
3
|
+
import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
|
|
4
4
|
//#region src/lib/markdown-to-tiptap.ts
|
|
5
5
|
/**
|
|
6
6
|
* Markdown → TipTap JSON Conversion
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./url-
|
|
2
|
-
import "./export-
|
|
3
|
-
import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-
|
|
1
|
+
import "./url-BMYO-Zlt.js";
|
|
2
|
+
import "./export-Be082J0n.js";
|
|
3
|
+
import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-D1Cw8mOY.js";
|
|
4
4
|
export { classifyRepoForSync, createGitHubSyncService };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-
|
|
3
|
-
import { T as time_exports, a as markdown_exports } from "./export-
|
|
4
|
-
import "./env-
|
|
5
|
-
import "./github-sync-
|
|
1
|
+
import { y as url_exports } from "./url-BMYO-Zlt.js";
|
|
2
|
+
import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-9P4rVCe2.js";
|
|
3
|
+
import { T as time_exports, a as markdown_exports } from "./export-Be082J0n.js";
|
|
4
|
+
import "./env-OHRKGcMj.js";
|
|
5
|
+
import "./github-sync-D1Cw8mOY.js";
|
|
6
6
|
export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
|
package/dist/node.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import "./url-
|
|
2
|
-
import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-
|
|
3
|
-
import { t as createExportService } from "./export-
|
|
4
|
-
import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-
|
|
5
|
-
import "./github-sync-
|
|
1
|
+
import "./url-BMYO-Zlt.js";
|
|
2
|
+
import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-9P4rVCe2.js";
|
|
3
|
+
import { t as createExportService } from "./export-Be082J0n.js";
|
|
4
|
+
import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-OHRKGcMj.js";
|
|
5
|
+
import "./github-sync-D1Cw8mOY.js";
|
|
6
6
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
7
7
|
import { serve } from "@hono/node-server";
|
|
8
8
|
import Database from "better-sqlite3";
|
|
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
|
|
|
529
529
|
async function start(env = process.env, app) {
|
|
530
530
|
const handler = await createNodeRequestHandler({
|
|
531
531
|
env,
|
|
532
|
-
app: async () => app ?? (await import("./app-
|
|
532
|
+
app: async () => app ?? (await import("./app-DaxS_Cz-.js")).createApp()
|
|
533
533
|
});
|
|
534
534
|
const hostname = resolveHost(env);
|
|
535
535
|
const port = resolvePort(env);
|
|
@@ -32,7 +32,8 @@ var __exportAll = (all, no_symbols) => {
|
|
|
32
32
|
toAbsoluteAssetUrl: () => toAbsoluteAssetUrl,
|
|
33
33
|
toAbsoluteSiteUrl: () => toAbsoluteSiteUrl,
|
|
34
34
|
toPublicHref: () => toPublicHref,
|
|
35
|
-
toPublicPath: () => toPublicPath
|
|
35
|
+
toPublicPath: () => toPublicPath,
|
|
36
|
+
toSameSitePath: () => toSameSitePath
|
|
36
37
|
});
|
|
37
38
|
function normalizeSitePathname(pathname) {
|
|
38
39
|
if (pathname === "/" || pathname === "") return "";
|
|
@@ -109,6 +110,45 @@ function normalizeSitePathname(pathname) {
|
|
|
109
110
|
return str.startsWith("http://") || str.startsWith("https://");
|
|
110
111
|
}
|
|
111
112
|
/**
|
|
113
|
+
* If a full URL points at the site's own host, return its same-site path
|
|
114
|
+
* (`pathname` + `search` + `hash`). Returns `null` when the input is not a full
|
|
115
|
+
* URL, is unparseable, or points at a different host.
|
|
116
|
+
*
|
|
117
|
+
* Self-referential absolute links — e.g. a nav item set to
|
|
118
|
+
* `https://example.com/about` on `example.com` — should behave like the
|
|
119
|
+
* internal path `/about`: no external-link icon, no `target="_blank"`.
|
|
120
|
+
*
|
|
121
|
+
* Matching is by **hostname**, not full origin: scheme and port differences are
|
|
122
|
+
* treated as same-site. This keeps the check intuitive ("same domain") and
|
|
123
|
+
* robust in dev, where the site is often served over `http://host:<port>` while
|
|
124
|
+
* a nav link stores the canonical `https://host` URL. In production (canonical
|
|
125
|
+
* https + default port) hostname match is equivalent to origin match.
|
|
126
|
+
*
|
|
127
|
+
* @param url - Candidate URL (full URL or relative path)
|
|
128
|
+
* @param siteOrigin - The site's own origin, e.g. `https://example.com`
|
|
129
|
+
* @returns The same-site path, or `null` when the URL is external/non-absolute
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* toSameSitePath("https://example.com/about", "https://example.com"); // "/about"
|
|
134
|
+
* toSameSitePath("https://example.com/about", "http://example.com:8787"); // "/about"
|
|
135
|
+
* toSameSitePath("https://other.com/about", "https://example.com"); // null
|
|
136
|
+
* toSameSitePath("/about", "https://example.com"); // null
|
|
137
|
+
* ```
|
|
138
|
+
*/ function toSameSitePath(url, siteOrigin) {
|
|
139
|
+
if (!siteOrigin || !isFullUrl(url)) return null;
|
|
140
|
+
let parsed;
|
|
141
|
+
let reference;
|
|
142
|
+
try {
|
|
143
|
+
parsed = new URL(url);
|
|
144
|
+
reference = new URL(siteOrigin);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (parsed.hostname !== reference.hostname) return null;
|
|
149
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
112
152
|
* Converts text to a URL-friendly slug.
|
|
113
153
|
*
|
|
114
154
|
* Transforms text into a lowercase, hyphen-separated slug using limax for
|
|
@@ -352,4 +392,4 @@ var SAFE_URL_PROTOCOLS = new Set([
|
|
|
352
392
|
return toAbsoluteSiteUrl(url, siteUrl, sitePathPrefix);
|
|
353
393
|
}
|
|
354
394
|
//#endregion
|
|
355
|
-
export { toPublicPath as _, getSitePathPrefix as a, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicHref as g, toAbsoluteSiteUrl as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteAssetUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u,
|
|
395
|
+
export { toPublicPath as _, getSitePathPrefix as a, __exportAll as b, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicHref as g, toAbsoluteSiteUrl as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteAssetUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, toSameSitePath as v, url_exports as y };
|
package/package.json
CHANGED
|
@@ -41,6 +41,7 @@ describe("jant uploads cleanup", () => {
|
|
|
41
41
|
JSON.stringify({
|
|
42
42
|
abortedMultipartUploads: 1,
|
|
43
43
|
deletedSessions: 3,
|
|
44
|
+
deletedOrphanMedia: 2,
|
|
44
45
|
}),
|
|
45
46
|
{
|
|
46
47
|
status: 200,
|
|
@@ -73,5 +74,6 @@ describe("jant uploads cleanup", () => {
|
|
|
73
74
|
);
|
|
74
75
|
expect(logSpy).toHaveBeenNthCalledWith(2, "Deleted sessions: 3");
|
|
75
76
|
expect(logSpy).toHaveBeenNthCalledWith(3, "Aborted multipart uploads: 1");
|
|
77
|
+
expect(logSpy).toHaveBeenNthCalledWith(4, "Orphaned media deleted: 2");
|
|
76
78
|
});
|
|
77
79
|
});
|
|
@@ -459,6 +459,111 @@ describe("compose bridge", () => {
|
|
|
459
459
|
expect(composeEl.clearLocalDraftFromStorage).not.toHaveBeenCalled();
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
+
it("re-binds the thread-context Show more toggle after a reply swaps in a thread preview", async () => {
|
|
463
|
+
document.body.innerHTML = `
|
|
464
|
+
<div data-timeline-item data-thread-root-id="pst_root">
|
|
465
|
+
<div data-timeline-item-content>
|
|
466
|
+
<article data-post data-post-id="pst_root">Root</article>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
`;
|
|
470
|
+
|
|
471
|
+
const composeEl = document.createElement(
|
|
472
|
+
"jant-compose-dialog",
|
|
473
|
+
) as ComposeHarness;
|
|
474
|
+
composeEl.refreshCollections = vi.fn(async () => true);
|
|
475
|
+
composeEl.pageMode = false;
|
|
476
|
+
document.body.appendChild(composeEl);
|
|
477
|
+
|
|
478
|
+
// The freshly rendered thread preview carries the collapsed ancestor shell
|
|
479
|
+
// plus its "Show more" toggle — the markup the bug left inert until reload.
|
|
480
|
+
const threadPreviewHtml = `
|
|
481
|
+
<div class="thread-group thread-group-preview">
|
|
482
|
+
<div class="thread-context-shell" data-thread-context data-collapsed="">
|
|
483
|
+
<div class="thread-item"><article data-post>Root</article></div>
|
|
484
|
+
</div>
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
class="thread-context-toggle"
|
|
488
|
+
data-thread-context-toggle
|
|
489
|
+
data-label-more="Show more"
|
|
490
|
+
data-label-less="Show less"
|
|
491
|
+
aria-expanded="false"
|
|
492
|
+
>
|
|
493
|
+
<span class="thread-context-toggle-label">Show more</span>
|
|
494
|
+
</button>
|
|
495
|
+
<div class="thread-item thread-item-hero"><article data-post>Reply</article></div>
|
|
496
|
+
</div>
|
|
497
|
+
`;
|
|
498
|
+
|
|
499
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
500
|
+
const raw =
|
|
501
|
+
typeof input === "string"
|
|
502
|
+
? input
|
|
503
|
+
: input instanceof URL
|
|
504
|
+
? input.toString()
|
|
505
|
+
: input.url;
|
|
506
|
+
const url = new URL(raw, "http://localhost");
|
|
507
|
+
|
|
508
|
+
if (url.pathname === "/compose") {
|
|
509
|
+
return new Response(
|
|
510
|
+
JSON.stringify({ status: "published", permalink: "/the-reply" }),
|
|
511
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (url.pathname === "/_/timeline-item/pst_root") {
|
|
516
|
+
return new Response(threadPreviewHtml, {
|
|
517
|
+
status: 200,
|
|
518
|
+
headers: { "Content-Type": "text/html" },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
throw new Error(`Unexpected fetch: ${url.pathname}`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
composeEl.dispatchEvent(
|
|
526
|
+
new CustomEvent("jant:compose-submit-deferred", {
|
|
527
|
+
bubbles: true,
|
|
528
|
+
detail: {
|
|
529
|
+
format: "note",
|
|
530
|
+
title: "",
|
|
531
|
+
body: "A reply",
|
|
532
|
+
url: "",
|
|
533
|
+
quoteText: "",
|
|
534
|
+
quoteAuthor: "",
|
|
535
|
+
slug: "",
|
|
536
|
+
status: "published",
|
|
537
|
+
visibility: "public",
|
|
538
|
+
rating: 0,
|
|
539
|
+
collectionIds: [],
|
|
540
|
+
attachments: [],
|
|
541
|
+
pendingAttachments: [],
|
|
542
|
+
replyToId: "pst_parent",
|
|
543
|
+
replyThreadRootId: "pst_root",
|
|
544
|
+
replyRefreshKind: "timeline-item",
|
|
545
|
+
replyRefreshId: "pst_root",
|
|
546
|
+
},
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
await flushBridgeWork();
|
|
551
|
+
|
|
552
|
+
const toggle = document.querySelector<HTMLElement>(
|
|
553
|
+
"[data-thread-context-toggle]",
|
|
554
|
+
);
|
|
555
|
+
const shell = document.querySelector<HTMLElement>("[data-thread-context]");
|
|
556
|
+
expect(toggle).not.toBeNull();
|
|
557
|
+
expect(shell).not.toBeNull();
|
|
558
|
+
// setupThreadContexts ran on the swapped-in markup.
|
|
559
|
+
expect(toggle?.dataset.threadContextToggleBound).toBe("1");
|
|
560
|
+
|
|
561
|
+
// And the bound listener actually toggles the collapsed state.
|
|
562
|
+
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
563
|
+
expect(shell?.dataset.collapsed).toBeUndefined();
|
|
564
|
+
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
|
565
|
+
});
|
|
566
|
+
|
|
462
567
|
it("sends nulls for cleared quote attribution fields when editing", async () => {
|
|
463
568
|
const composeEl = document.createElement(
|
|
464
569
|
"jant-compose-dialog",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
// Stub the initializer modules so importing hydrate-partial doesn't run their
|
|
6
|
+
// module-load side effects (DOMContentLoaded registration, observers), and so we
|
|
7
|
+
// can assert hydratePartial forwards the root to each one.
|
|
8
|
+
vi.mock("../thread-context.js", () => ({ setupThreadContexts: vi.fn() }));
|
|
9
|
+
vi.mock("../feed-video-player.js", () => ({ initFeedVideoPlayer: vi.fn() }));
|
|
10
|
+
vi.mock("../audio-player.js", () => ({ initPrecomputedWaveforms: vi.fn() }));
|
|
11
|
+
|
|
12
|
+
import { hydratePartial } from "../hydrate-partial.js";
|
|
13
|
+
import { setupThreadContexts } from "../thread-context.js";
|
|
14
|
+
import { initFeedVideoPlayer } from "../feed-video-player.js";
|
|
15
|
+
import { initPrecomputedWaveforms } from "../audio-player.js";
|
|
16
|
+
|
|
17
|
+
describe("hydratePartial", () => {
|
|
18
|
+
it("re-initializes per-element behaviors scoped to the swapped root", () => {
|
|
19
|
+
const root = document.createElement("div");
|
|
20
|
+
|
|
21
|
+
hydratePartial(root);
|
|
22
|
+
|
|
23
|
+
expect(setupThreadContexts).toHaveBeenCalledWith(root);
|
|
24
|
+
expect(initFeedVideoPlayer).toHaveBeenCalledWith(root);
|
|
25
|
+
expect(initPrecomputedWaveforms).toHaveBeenCalledWith(root);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
readErrorMessage,
|
|
5
|
+
readErrorMessageFromText,
|
|
6
|
+
readJsonObject,
|
|
7
|
+
} from "../json.js";
|
|
8
|
+
|
|
9
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textResponse(body: string, status: number): Response {
|
|
17
|
+
return new Response(body, {
|
|
18
|
+
status,
|
|
19
|
+
headers: { "Content-Type": "text/plain" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("readJsonObject", () => {
|
|
24
|
+
it("parses a JSON object body", async () => {
|
|
25
|
+
const res = jsonResponse({ id: "abc", count: 2 });
|
|
26
|
+
expect(await readJsonObject(res)).toEqual({ id: "abc", count: 2 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns empty object for empty body", async () => {
|
|
30
|
+
const res = new Response("", { status: 200 });
|
|
31
|
+
expect(await readJsonObject(res)).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns empty object for a JSON primitive", async () => {
|
|
35
|
+
expect(await readJsonObject(jsonResponse("hi"))).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws an informative error when body is not JSON", async () => {
|
|
39
|
+
const res = textResponse("Not Found", 404);
|
|
40
|
+
await expect(readJsonObject(res)).rejects.toThrow(
|
|
41
|
+
/Expected JSON \(HTTP 404\) but got: Not Found/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("readErrorMessage", () => {
|
|
47
|
+
it("returns the JSON `error` field when present", async () => {
|
|
48
|
+
const res = jsonResponse({ error: "Quota exceeded" }, 400);
|
|
49
|
+
expect(await readErrorMessage(res, "Default")).toBe("Quota exceeded");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("surfaces plain-text body when not JSON (the Not Found case)", async () => {
|
|
53
|
+
const res = textResponse("Not Found", 404);
|
|
54
|
+
expect(await readErrorMessage(res, "Failed to start upload")).toBe(
|
|
55
|
+
"Not Found",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back when body is empty", async () => {
|
|
60
|
+
const res = new Response("", { status: 500 });
|
|
61
|
+
expect(await readErrorMessage(res, "Default")).toBe("Default");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("truncates very long bodies (e.g. error HTML pages)", async () => {
|
|
65
|
+
const long = "x".repeat(500);
|
|
66
|
+
const res = textResponse(long, 502);
|
|
67
|
+
const result = await readErrorMessage(res, "Default");
|
|
68
|
+
expect(result.length).toBeLessThanOrEqual(201);
|
|
69
|
+
expect(result.endsWith("…")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back when JSON has no `error` field", async () => {
|
|
73
|
+
const res = jsonResponse({ status: "bad" }, 400);
|
|
74
|
+
expect(await readErrorMessage(res, "Default")).toBe('{"status":"bad"}');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("readErrorMessageFromText", () => {
|
|
79
|
+
it("extracts error from JSON text", () => {
|
|
80
|
+
expect(
|
|
81
|
+
readErrorMessageFromText('{"error":"part too small"}', "Default"),
|
|
82
|
+
).toBe("part too small");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns plain text when not JSON", () => {
|
|
86
|
+
expect(readErrorMessageFromText("Internal Error", "Default")).toBe(
|
|
87
|
+
"Internal Error",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back when text is blank", () => {
|
|
92
|
+
expect(readErrorMessageFromText(" ", "Default")).toBe("Default");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import "../note-expand.js";
|
|
5
|
+
|
|
6
|
+
interface CardOptions {
|
|
7
|
+
clamped?: boolean;
|
|
8
|
+
withMarker?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildCard(options: CardOptions = {}) {
|
|
12
|
+
const { clamped = true, withMarker = true } = options;
|
|
13
|
+
|
|
14
|
+
const article = document.createElement("article");
|
|
15
|
+
article.setAttribute("data-post", "");
|
|
16
|
+
|
|
17
|
+
const body = document.createElement("div");
|
|
18
|
+
body.setAttribute("data-post-body", "");
|
|
19
|
+
if (clamped) body.setAttribute("data-note-clamp", "");
|
|
20
|
+
body.innerHTML = withMarker
|
|
21
|
+
? "<p>Summary</p><span data-note-break></span><p>Rest</p>"
|
|
22
|
+
: "<p>Summary</p>";
|
|
23
|
+
|
|
24
|
+
const control = document.createElement("a");
|
|
25
|
+
control.setAttribute("data-note-expand", "");
|
|
26
|
+
control.setAttribute("aria-expanded", "false");
|
|
27
|
+
control.setAttribute("href", "/post-1");
|
|
28
|
+
control.dataset.labelMore = "Read more";
|
|
29
|
+
control.dataset.labelLess = "Read less";
|
|
30
|
+
control.textContent = "Read more";
|
|
31
|
+
|
|
32
|
+
article.appendChild(body);
|
|
33
|
+
article.appendChild(control);
|
|
34
|
+
document.body.appendChild(article);
|
|
35
|
+
return { article, body, control };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Dispatch a click; returns false when a handler called preventDefault. */
|
|
39
|
+
function click(
|
|
40
|
+
el: HTMLElement,
|
|
41
|
+
init: { metaKey?: boolean; button?: number } = {},
|
|
42
|
+
): boolean {
|
|
43
|
+
return el.dispatchEvent(
|
|
44
|
+
new MouseEvent("click", { bubbles: true, cancelable: true, ...init }),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("note expand", () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
document.body.innerHTML = "";
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reveals the tail by removing the clamp on expand", () => {
|
|
55
|
+
const { body, control } = buildCard();
|
|
56
|
+
|
|
57
|
+
const notCancelled = click(control);
|
|
58
|
+
|
|
59
|
+
expect(notCancelled).toBe(false);
|
|
60
|
+
expect(body.hasAttribute("data-note-clamp")).toBe(false);
|
|
61
|
+
expect(control.getAttribute("aria-expanded")).toBe("true");
|
|
62
|
+
expect(control.textContent).toBe("Read less");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("re-clamps the tail on collapse", () => {
|
|
66
|
+
const { body, control } = buildCard();
|
|
67
|
+
|
|
68
|
+
click(control);
|
|
69
|
+
click(control);
|
|
70
|
+
|
|
71
|
+
expect(body.hasAttribute("data-note-clamp")).toBe(true);
|
|
72
|
+
expect(control.getAttribute("aria-expanded")).toBe("false");
|
|
73
|
+
expect(control.textContent).toBe("Read more");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ignores modified clicks so the link opens normally", () => {
|
|
77
|
+
const { body, control } = buildCard();
|
|
78
|
+
|
|
79
|
+
const notCancelled = click(control, { metaKey: true });
|
|
80
|
+
|
|
81
|
+
expect(notCancelled).toBe(true);
|
|
82
|
+
expect(body.hasAttribute("data-note-clamp")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("leaves the link alone when the body has no break marker", () => {
|
|
86
|
+
const { body, control } = buildCard({ clamped: false, withMarker: false });
|
|
87
|
+
|
|
88
|
+
const notCancelled = click(control);
|
|
89
|
+
|
|
90
|
+
expect(notCancelled).toBe(true);
|
|
91
|
+
expect(body.hasAttribute("data-note-clamp")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("only toggles the clicked card", () => {
|
|
95
|
+
const a = buildCard();
|
|
96
|
+
const b = buildCard();
|
|
97
|
+
|
|
98
|
+
click(a.control);
|
|
99
|
+
|
|
100
|
+
expect(a.body.hasAttribute("data-note-clamp")).toBe(false);
|
|
101
|
+
expect(b.body.hasAttribute("data-note-clamp")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("scrolls the note top into view on collapse when scrolled past it", () => {
|
|
105
|
+
const { article, control } = buildCard();
|
|
106
|
+
click(control); // expand
|
|
107
|
+
|
|
108
|
+
const scrollIntoView = vi.fn();
|
|
109
|
+
article.scrollIntoView = scrollIntoView;
|
|
110
|
+
vi.spyOn(article, "getBoundingClientRect").mockReturnValue({
|
|
111
|
+
top: -50,
|
|
112
|
+
} as unknown as ReturnType<HTMLElement["getBoundingClientRect"]>);
|
|
113
|
+
|
|
114
|
+
click(control); // collapse
|
|
115
|
+
|
|
116
|
+
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps working after the card DOM is replaced", () => {
|
|
120
|
+
buildCard();
|
|
121
|
+
|
|
122
|
+
// Simulate compose-bridge replacing the card with a fresh collapsed render.
|
|
123
|
+
document.body.innerHTML = "";
|
|
124
|
+
const { body, control } = buildCard();
|
|
125
|
+
|
|
126
|
+
click(control);
|
|
127
|
+
|
|
128
|
+
expect(body.hasAttribute("data-note-clamp")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -65,7 +65,8 @@ document.querySelectorAll(".archive-chip-dropdown").forEach((chip) => {
|
|
|
65
65
|
} else {
|
|
66
66
|
url.searchParams.delete(filterKey);
|
|
67
67
|
}
|
|
68
|
-
//
|
|
68
|
+
// Kind toggles overwrite media=any/none via the shared param; also
|
|
69
|
+
// drop the legacy hasMedia param when acting on an old URL.
|
|
69
70
|
url.searchParams.delete("hasMedia");
|
|
70
71
|
url.searchParams.delete("page");
|
|
71
72
|
window.location.href = url.pathname + (url.search || "");
|
|
@@ -99,9 +99,11 @@ async function extractPeaks(url: string, count: number): Promise<number[]> {
|
|
|
99
99
|
* time to lay out the canvas (which starts as display:none and only
|
|
100
100
|
* becomes visible when the `has-waveform` class is added).
|
|
101
101
|
*/
|
|
102
|
-
function initPrecomputedWaveforms(
|
|
102
|
+
export function initPrecomputedWaveforms(
|
|
103
|
+
root: globalThis.Document | globalThis.Element = document,
|
|
104
|
+
) {
|
|
103
105
|
const canvases =
|
|
104
|
-
|
|
106
|
+
root.querySelectorAll<HTMLCanvasElement>("[data-audio-peaks]");
|
|
105
107
|
const cardsToRender: HTMLElement[] = [];
|
|
106
108
|
|
|
107
109
|
for (const canvas of canvases) {
|
|
@@ -472,7 +474,9 @@ document.addEventListener(
|
|
|
472
474
|
// --- Initialize pre-computed waveforms on page load ---
|
|
473
475
|
|
|
474
476
|
if (document.readyState === "loading") {
|
|
475
|
-
document.addEventListener("DOMContentLoaded",
|
|
477
|
+
document.addEventListener("DOMContentLoaded", () =>
|
|
478
|
+
initPrecomputedWaveforms(),
|
|
479
|
+
);
|
|
476
480
|
} else {
|
|
477
481
|
initPrecomputedWaveforms();
|
|
478
482
|
}
|