@jant/core 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -400,7 +400,7 @@ describe("PostService - Timeline features", () => {
400
400
  });
401
401
  });
402
402
 
403
- describe("getLastPostIdsByThread", () => {
403
+ describe("getThreadTailIds", () => {
404
404
  it("keeps independent root threads separate", async () => {
405
405
  const root1 = await postService.create({
406
406
  format: "note",
@@ -412,10 +412,7 @@ describe("PostService - Timeline features", () => {
412
412
  quoteText: "quoted",
413
413
  });
414
414
 
415
- const result = await postService.getLastPostIdsByThread([
416
- root1.id,
417
- root2.id,
418
- ]);
415
+ const result = await postService.getThreadTailIds([root1.id, root2.id]);
419
416
 
420
417
  expect(result.get(root1.id)).toBe(root1.id);
421
418
  expect(result.get(root2.id)).toBe(root2.id);
@@ -436,14 +433,57 @@ describe("PostService - Timeline features", () => {
436
433
  replyToId: root1.id,
437
434
  });
438
435
 
439
- const result = await postService.getLastPostIdsByThread([
440
- root1.id,
441
- root2.id,
442
- ]);
436
+ const result = await postService.getThreadTailIds([root1.id, root2.id]);
443
437
 
444
438
  expect(result.get(root1.id)).toBe(reply1.id);
445
439
  expect(result.get(root2.id)).toBe(root2.id);
446
440
  });
441
+
442
+ it("skips a trailing draft by default", async () => {
443
+ const root = await postService.create({
444
+ format: "note",
445
+ bodyMarkdown: "root",
446
+ });
447
+ const reply = await postService.create({
448
+ format: "note",
449
+ bodyMarkdown: "published reply",
450
+ replyToId: root.id,
451
+ });
452
+ await postService.create({
453
+ format: "note",
454
+ bodyMarkdown: "unfinished",
455
+ replyToId: reply.id,
456
+ status: "draft",
457
+ });
458
+
459
+ const result = await postService.getThreadTailIds([root.id]);
460
+
461
+ expect(result.get(root.id)).toBe(reply.id);
462
+ });
463
+
464
+ it("returns a trailing draft when drafts are included", async () => {
465
+ const root = await postService.create({
466
+ format: "note",
467
+ bodyMarkdown: "root",
468
+ });
469
+ const reply = await postService.create({
470
+ format: "note",
471
+ bodyMarkdown: "published reply",
472
+ replyToId: root.id,
473
+ });
474
+ const draft = await postService.create({
475
+ format: "note",
476
+ bodyMarkdown: "unfinished",
477
+ replyToId: reply.id,
478
+ status: "draft",
479
+ });
480
+
481
+ const result = await postService.getThreadTailIds([root.id], {
482
+ includeDrafts: true,
483
+ });
484
+
485
+ expect(result.get(root.id)).toBe(draft.id);
486
+ });
447
487
  });
448
488
 
449
489
  describe("timeline assembly", () => {
@@ -586,7 +626,98 @@ describe("PostService - Timeline features", () => {
586
626
  expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
587
627
  });
588
628
 
589
- it("orders a Thread by a newer root edit", async () => {
629
+ // Quietness used to live only in control flow: the reply skipped the
630
+ // recalculation and nothing recorded that it had. Any later recalculation
631
+ // recomputed MAX(published_at) over the whole Thread, swallowed the quiet
632
+ // reply, and bumped the Thread anyway. It is a stored column now, so the
633
+ // decision survives whatever else happens to the Thread.
634
+ it("keeps a quiet reply quiet after an unrelated post is deleted", async () => {
635
+ const collection = await collectionService.create({
636
+ slug: "durable",
637
+ title: "Durable",
638
+ });
639
+ const olderRoot = await postService.create({
640
+ format: "note",
641
+ bodyMarkdown: "Older root",
642
+ publishedAt: 1000,
643
+ });
644
+ const newerRoot = await postService.create({
645
+ format: "note",
646
+ bodyMarkdown: "Newer root",
647
+ publishedAt: 2000,
648
+ });
649
+ await collectionService.addThread(collection.id, olderRoot.id);
650
+ await collectionService.addThread(collection.id, newerRoot.id);
651
+
652
+ const normalReply = await postService.create({
653
+ format: "note",
654
+ bodyMarkdown: "Normal reply",
655
+ replyToId: olderRoot.id,
656
+ publishedAt: 1500,
657
+ });
658
+ await postService.create({
659
+ format: "note",
660
+ bodyMarkdown: "Quiet reply",
661
+ replyToId: normalReply.id,
662
+ publishedAt: 3000,
663
+ quietReply: true,
664
+ });
665
+
666
+ // Triggers a full recalculation of the Thread.
667
+ await postService.delete(normalReply.id);
668
+
669
+ const root = await postService.getById(olderRoot.id);
670
+ expect(root?.lastActivityAt).toBe(1000);
671
+ expect(root?.threadUpdatedAt).toBe(3000);
672
+
673
+ const rootIds = await postService.listCollectionThreadRootIds(
674
+ collection.id,
675
+ { status: "published", sortOrder: "newest" },
676
+ );
677
+ expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
678
+ });
679
+
680
+ it("records both activity timestamps for a quiet reply", async () => {
681
+ const root = await postService.create({
682
+ format: "note",
683
+ bodyMarkdown: "Root",
684
+ publishedAt: 1000,
685
+ });
686
+ await postService.create({
687
+ format: "note",
688
+ bodyMarkdown: "Quiet reply",
689
+ replyToId: root.id,
690
+ publishedAt: 3000,
691
+ quietReply: true,
692
+ });
693
+
694
+ const updated = await postService.getById(root.id);
695
+ expect(updated?.lastActivityAt).toBe(1000);
696
+ expect(updated?.threadUpdatedAt).toBe(3000);
697
+ });
698
+
699
+ it("moves both timestamps for a normal reply", async () => {
700
+ const root = await postService.create({
701
+ format: "note",
702
+ bodyMarkdown: "Root",
703
+ publishedAt: 1000,
704
+ });
705
+ await postService.create({
706
+ format: "note",
707
+ bodyMarkdown: "Normal reply",
708
+ replyToId: root.id,
709
+ publishedAt: 3000,
710
+ });
711
+
712
+ const updated = await postService.getById(root.id);
713
+ expect(updated?.lastActivityAt).toBe(3000);
714
+ expect(updated?.threadUpdatedAt).toBe(3000);
715
+ });
716
+
717
+ // Activity means the Thread gained a post. Editing an old post is not
718
+ // activity — otherwise fixing a typo would drag it back to the top of its
719
+ // collection, and "recently updated" would stop meaning anything.
720
+ it("does not reorder a Thread when its root is only edited", async () => {
590
721
  const collection = await collectionService.create({
591
722
  slug: "edited",
592
723
  title: "Edited",
@@ -617,7 +748,7 @@ describe("PostService - Timeline features", () => {
617
748
  },
618
749
  );
619
750
 
620
- expect(rootIds).toEqual([editedRoot.id, newerRoot.id]);
751
+ expect(rootIds).toEqual([newerRoot.id, editedRoot.id]);
621
752
  });
622
753
  });
623
754
 
@@ -1470,6 +1470,9 @@ describe("PostService", () => {
1470
1470
  expect(published?.status).toBe("published");
1471
1471
  expect(published?.publishedAt).toBeTypeOf("number");
1472
1472
  expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
1473
+ expect(
1474
+ (await postService.list({ status: "draft" })).map((draft) => draft.id),
1475
+ ).not.toContain(post.id);
1473
1476
  });
1474
1477
 
1475
1478
  it("clears publishedAt when converting a published post back to draft", async () => {
@@ -1856,6 +1859,92 @@ describe("PostService", () => {
1856
1859
  });
1857
1860
  });
1858
1861
 
1862
+ describe("createThreadWithAttachments", () => {
1863
+ // No attachments in these cases, so only the up-front id check is reached.
1864
+ const deps = {
1865
+ media: { validateIds: async () => [] },
1866
+ } as unknown as Parameters<
1867
+ typeof postService.createThreadWithAttachments
1868
+ >[1];
1869
+
1870
+ it("dates every reply from the root when the root is backdated", async () => {
1871
+ const backdated = 1710000000; // 2024-03-09
1872
+
1873
+ const created = await postService.createThreadWithAttachments(
1874
+ [
1875
+ {
1876
+ data: {
1877
+ format: "note",
1878
+ bodyMarkdown: "root",
1879
+ publishedAt: backdated,
1880
+ },
1881
+ attachments: [],
1882
+ },
1883
+ { data: { format: "note", bodyMarkdown: "second" }, attachments: [] },
1884
+ { data: { format: "note", bodyMarkdown: "third" }, attachments: [] },
1885
+ ],
1886
+ deps,
1887
+ undefined,
1888
+ );
1889
+
1890
+ // Without inheritance the replies would land on today and the thread
1891
+ // would read as if it spanned two years.
1892
+ expect(created.map((p) => p.publishedAt)).toEqual([
1893
+ backdated,
1894
+ backdated,
1895
+ backdated,
1896
+ ]);
1897
+ // Order still comes from createdAt/id, so the chain is intact.
1898
+ expect(created[1].replyToId).toBe(created[0].id);
1899
+ expect(created[2].replyToId).toBe(created[1].id);
1900
+ });
1901
+
1902
+ it("keeps a reply's own date when one is given", async () => {
1903
+ const rootAt = 1710000000;
1904
+ const replyAt = 1720000000;
1905
+
1906
+ const created = await postService.createThreadWithAttachments(
1907
+ [
1908
+ {
1909
+ data: { format: "note", bodyMarkdown: "root", publishedAt: rootAt },
1910
+ attachments: [],
1911
+ },
1912
+ {
1913
+ data: {
1914
+ format: "note",
1915
+ bodyMarkdown: "second",
1916
+ publishedAt: replyAt,
1917
+ },
1918
+ attachments: [],
1919
+ },
1920
+ ],
1921
+ deps,
1922
+ undefined,
1923
+ );
1924
+
1925
+ expect(created.map((p) => p.publishedAt)).toEqual([rootAt, replyAt]);
1926
+ });
1927
+
1928
+ it("leaves draft threads undated", async () => {
1929
+ const created = await postService.createThreadWithAttachments(
1930
+ [
1931
+ {
1932
+ data: { format: "note", bodyMarkdown: "root", status: "draft" },
1933
+ attachments: [],
1934
+ },
1935
+ {
1936
+ data: { format: "note", bodyMarkdown: "second", status: "draft" },
1937
+ attachments: [],
1938
+ },
1939
+ ],
1940
+ deps,
1941
+ undefined,
1942
+ );
1943
+
1944
+ expect(created.map((p) => p.publishedAt)).toEqual([null, null]);
1945
+ });
1946
+ });
1947
+
1859
1948
  describe("threads", () => {
1860
1949
  it("sets threadId on reply to a root post", async () => {
1861
1950
  const root = await postService.create({
@@ -1915,6 +2004,143 @@ describe("PostService", () => {
1915
2004
  );
1916
2005
  });
1917
2006
 
2007
+ it("refuses to feature an unpublished post", async () => {
2008
+ await expect(
2009
+ postService.create({
2010
+ format: "note",
2011
+ bodyMarkdown: "not ready",
2012
+ status: "draft",
2013
+ featured: true,
2014
+ }),
2015
+ ).rejects.toThrow("Publish this post before featuring it.");
2016
+
2017
+ const draft = await postService.create({
2018
+ format: "note",
2019
+ bodyMarkdown: "not ready",
2020
+ status: "draft",
2021
+ });
2022
+ await expect(
2023
+ postService.update(draft.id, { featured: true }),
2024
+ ).rejects.toThrow("Publish this post before featuring it.");
2025
+ });
2026
+
2027
+ it("keeps a featured flag through a trip back to draft", async () => {
2028
+ const post = await postService.create({
2029
+ format: "note",
2030
+ bodyMarkdown: "published",
2031
+ featured: true,
2032
+ });
2033
+
2034
+ const unpublished = await postService.update(post.id, {
2035
+ status: "draft",
2036
+ });
2037
+
2038
+ expect(unpublished?.status).toBe("draft");
2039
+ expect(unpublished?.featuredAt).not.toBeNull();
2040
+ });
2041
+
2042
+ it("publishes a reply whose parent is still a draft", async () => {
2043
+ const root = await postService.create({
2044
+ format: "note",
2045
+ bodyMarkdown: "root",
2046
+ });
2047
+ const draft = await postService.create({
2048
+ format: "note",
2049
+ bodyMarkdown: "unfinished",
2050
+ replyToId: root.id,
2051
+ status: "draft",
2052
+ });
2053
+
2054
+ const next = await postService.create({
2055
+ format: "note",
2056
+ bodyMarkdown: "continues past the draft",
2057
+ replyToId: draft.id,
2058
+ });
2059
+
2060
+ // Readers never saw the draft, so publishing past it reads as
2061
+ // continuous. The draft stays parked until it is dealt with on its own.
2062
+ expect(next.status).toBe("published");
2063
+ expect(draft.status).toBe("draft");
2064
+ });
2065
+
2066
+ it("publishes only the post asked for, leaving other drafts alone", async () => {
2067
+ const root = await postService.create({
2068
+ format: "note",
2069
+ bodyMarkdown: "root",
2070
+ });
2071
+ const first = await postService.create({
2072
+ format: "note",
2073
+ bodyMarkdown: "first draft",
2074
+ replyToId: root.id,
2075
+ status: "draft",
2076
+ });
2077
+ const second = await postService.create({
2078
+ format: "note",
2079
+ bodyMarkdown: "second draft",
2080
+ replyToId: first.id,
2081
+ status: "draft",
2082
+ });
2083
+
2084
+ await postService.update(second.id, { status: "published" });
2085
+
2086
+ const statusById = new Map(
2087
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2088
+ );
2089
+ expect(statusById.get(first.id)).toBe("draft");
2090
+ expect(statusById.get(second.id)).toBe("published");
2091
+ });
2092
+
2093
+ it("keeps a draft between two published replies", async () => {
2094
+ const root = await postService.create({
2095
+ format: "note",
2096
+ bodyMarkdown: "root",
2097
+ });
2098
+ const middle = await postService.create({
2099
+ format: "note",
2100
+ bodyMarkdown: "middle",
2101
+ replyToId: root.id,
2102
+ });
2103
+ const last = await postService.create({
2104
+ format: "note",
2105
+ bodyMarkdown: "last",
2106
+ replyToId: middle.id,
2107
+ });
2108
+
2109
+ await postService.update(middle.id, { status: "draft" });
2110
+
2111
+ const statusById = new Map(
2112
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2113
+ );
2114
+ // Unpublishing one post touches only that post. The public thread simply
2115
+ // skips it; nothing else in the chain changes.
2116
+ expect(statusById.get(root.id)).toBe("published");
2117
+ expect(statusById.get(middle.id)).toBe("draft");
2118
+ expect(statusById.get(last.id)).toBe("published");
2119
+ });
2120
+
2121
+ it("names the draft when an unpublished reply holds the thread tail", async () => {
2122
+ const root = await postService.create({
2123
+ format: "note",
2124
+ bodyMarkdown: "root",
2125
+ });
2126
+ await postService.create({
2127
+ format: "note",
2128
+ bodyMarkdown: "unfinished",
2129
+ replyToId: root.id,
2130
+ status: "draft",
2131
+ });
2132
+
2133
+ await expect(
2134
+ postService.create({
2135
+ format: "note",
2136
+ bodyMarkdown: "reply",
2137
+ replyToId: root.id,
2138
+ }),
2139
+ ).rejects.toThrow(
2140
+ "This thread ends with an unpublished draft. Finish that draft or discard it, then reply.",
2141
+ );
2142
+ });
2143
+
1918
2144
  it("inherits status from root post", async () => {
1919
2145
  const root = await postService.create({
1920
2146
  format: "note",
@@ -2025,6 +2251,57 @@ describe("PostService", () => {
2025
2251
  expect(thread[0]?.bodyText).toBe("root");
2026
2252
  });
2027
2253
 
2254
+ it("getThreadPosition counts the chain from the root down to a post", async () => {
2255
+ const root = await postService.create({
2256
+ format: "note",
2257
+ bodyMarkdown: "root",
2258
+ });
2259
+ const reply1 = await postService.create({
2260
+ format: "note",
2261
+ bodyMarkdown: "reply1",
2262
+ replyToId: root.id,
2263
+ });
2264
+ const reply2 = await postService.create({
2265
+ format: "note",
2266
+ bodyMarkdown: "reply2",
2267
+ replyToId: reply1.id,
2268
+ });
2269
+
2270
+ expect(await postService.getThreadPosition(root.id)).toBe(1);
2271
+ expect(await postService.getThreadPosition(reply1.id)).toBe(2);
2272
+ expect(await postService.getThreadPosition(reply2.id)).toBe(3);
2273
+ });
2274
+
2275
+ it("getThreadPosition walks the reply chain rather than counting members", async () => {
2276
+ // `create` only allows replying to the tail, so today a thread is a
2277
+ // straight chain and depth happens to equal its size. The walk is what
2278
+ // makes the two agree — counting `threadId` members would give the same
2279
+ // answer here and the wrong one the moment a draft chain is incomplete.
2280
+ const root = await postService.create({
2281
+ format: "note",
2282
+ bodyMarkdown: "root",
2283
+ });
2284
+ const tail = await postService.create({
2285
+ format: "note",
2286
+ bodyMarkdown: "tail",
2287
+ replyToId: root.id,
2288
+ });
2289
+
2290
+ // A second post filed under the same thread without joining the chain:
2291
+ // it is not above the tail, so it must not push the tail's position.
2292
+ await postService.create({
2293
+ format: "note",
2294
+ bodyMarkdown: "unrelated",
2295
+ });
2296
+
2297
+ expect(await postService.getThreadPosition(tail.id)).toBe(2);
2298
+ expect(await postService.getThread(root.id)).toHaveLength(2);
2299
+ });
2300
+
2301
+ it("getThreadPosition returns 0 for a post that does not exist", async () => {
2302
+ expect(await postService.getThreadPosition("pst_missing")).toBe(0);
2303
+ });
2304
+
2028
2305
  it("getThread excludes deleted posts", async () => {
2029
2306
  const root = await postService.create({
2030
2307
  format: "note",
@@ -15,6 +15,7 @@ import {
15
15
  supportsDrizzleTransaction,
16
16
  } from "../db/index.js";
17
17
  import type { DatabaseDialect } from "../db/dialect.js";
18
+ import { buildRootActivityExpr } from "../db/thread-activity.js";
18
19
  import {
19
20
  sqliteSchemaBundle,
20
21
  type DatabaseSchema,
@@ -448,16 +449,13 @@ export function createCollectionService(
448
449
  async function listDirectoryCollections(): Promise<
449
450
  CollectionDirectoryCollection[]
450
451
  > {
451
- const threadActivityAt = sql<number>`CASE
452
- WHEN ${posts.updatedAt} > ${posts.createdAt}
453
- AND ${posts.updatedAt} > COALESCE(${posts.lastActivityAt}, -1)
454
- THEN ${posts.updatedAt}
455
- ELSE COALESCE(
456
- ${posts.lastActivityAt},
457
- ${posts.publishedAt},
458
- ${posts.updatedAt}
459
- )
460
- END`;
452
+ // `posts` is joined on thread_collections.thread_id, so each row is
453
+ // already the Thread root — the definition applies to it directly.
454
+ const threadActivityAt = buildRootActivityExpr({
455
+ lastActivityAt: posts.lastActivityAt,
456
+ publishedAt: posts.publishedAt,
457
+ updatedAt: posts.updatedAt,
458
+ });
461
459
  const threadCount = sql<number>`
462
460
  CAST(COUNT(
463
461
  CASE
@@ -1,2 +1,2 @@
1
- @keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{background-color:#000;border-radius:4px;max-width:100%;max-height:100%;display:block;position:relative;overflow:hidden}.media-lightbox-short-frame-contained{width:var(--media-lightbox-short-width);height:var(--media-lightbox-short-height)}.media-lightbox-short-viewport{width:100%;height:100%;min-height:0}.media-lightbox-video-short{object-fit:contain;cursor:pointer;width:100%;max-width:none;height:100%;max-height:none;display:block}.media-lightbox-short-controls{z-index:2;box-sizing:border-box;pointer-events:none;background:linear-gradient(#0000,#000000ad);align-items:center;gap:2px;height:44px;padding:0 6px;transition:opacity .18s,transform .18s;display:flex;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;flex:auto;width:0;min-width:48px;height:44px;margin:0;padding:0;display:block;position:relative}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-playback{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-playback svg{width:16px;height:16px;display:block}.media-lightbox-short-playback:hover{background-color:#ffffff24}.media-lightbox-short-playback:active{transform:scale(.92)}.media-lightbox-short-playback:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#ffffff24}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-frame{border-radius:0}}@media (hover:none),(pointer:coarse){.media-lightbox-short-frame-contained{height:calc(var(--media-lightbox-short-height) + 44px);grid-template-rows:minmax(0,1fr) 44px;display:grid}.media-lightbox-short-controls{background:#000;grid-row:2;position:relative}}@media (hover:hover) and (pointer:fine){.media-lightbox-short-controls{opacity:0;transform:translateY(4px)}.media-lightbox-short-controls>*{pointer-events:none}.media-lightbox-short-frame:hover .media-lightbox-short-controls,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls{opacity:1;transform:translateY(0)}.media-lightbox-short-frame:hover .media-lightbox-short-controls>*,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls>*{pointer-events:auto}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}
1
+ @keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{background-color:#000;border-radius:4px;max-width:100%;max-height:100%;display:block;position:relative;overflow:hidden}.media-lightbox-short-frame-contained{width:var(--media-lightbox-short-width);height:var(--media-lightbox-short-height)}.media-lightbox-short-viewport{width:100%;height:100%;min-height:0}.media-lightbox-video-short{object-fit:contain;cursor:pointer;width:100%;max-width:none;height:100%;max-height:none;display:block}.media-lightbox-short-controls{z-index:2;box-sizing:border-box;pointer-events:none;background:linear-gradient(#0000,#000000ad);align-items:center;gap:2px;height:50px;padding:0 6px 6px;transition:opacity .18s,transform .18s;display:flex;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;flex:auto;width:0;min-width:48px;height:44px;margin:0;padding:0;display:block;position:relative}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-playback{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-playback svg{width:16px;height:16px;display:block}.media-lightbox-short-playback:hover{background-color:#ffffff24}.media-lightbox-short-playback:active{transform:scale(.92)}.media-lightbox-short-playback:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#ffffff24}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-frame{border-radius:0}}@media (hover:none),(pointer:coarse){.media-lightbox-short-frame-contained{height:calc(var(--media-lightbox-short-height) + 50px);grid-template-rows:minmax(0,1fr) 50px;display:grid}.media-lightbox-short-controls{background:#000;grid-row:2;position:relative}}@media (hover:hover) and (pointer:fine){.media-lightbox-short-controls{opacity:0;transform:translateY(4px)}.media-lightbox-short-controls>*{pointer-events:none}.media-lightbox-short-frame:hover .media-lightbox-short-controls,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls{opacity:1;transform:translateY(0)}.media-lightbox-short-frame:hover .media-lightbox-short-controls>*,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls>*{pointer-events:auto}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}
2
2
  /*$vite$:1*/
@@ -27,6 +27,7 @@ import {
27
27
  getDefaultJantAppleTouchIconBytes,
28
28
  getDefaultJantFaviconIcoBytes,
29
29
  } from "../lib/jant-branding.js";
30
+ import { getRootActivityAt } from "../db/thread-activity.js";
30
31
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
31
32
  import { extractBodyText } from "../lib/summary.js";
32
33
  import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
@@ -974,6 +975,7 @@ async function buildThreadBundle(
974
975
  source_url: reply.format === "quote" && reply.url ? reply.url : undefined,
975
976
  quote_text: reply.quoteText ?? undefined,
976
977
  rating: reply.rating ?? undefined,
978
+ quiet_reply: reply.quietReply ? true : undefined,
977
979
  featured_at:
978
980
  reply.featuredAt !== null ? toISOString(reply.featuredAt) : undefined,
979
981
  pinned_at:
@@ -1326,11 +1328,7 @@ function buildExportedCollectionMetrics(
1326
1328
  continue;
1327
1329
  }
1328
1330
 
1329
- const activityAt =
1330
- post.lastActivityAt ??
1331
- post.publishedAt ??
1332
- post.updatedAt ??
1333
- post.createdAt;
1331
+ const activityAt = getRootActivityAt(post);
1334
1332
  const threadCollections = collectionsByThread.get(post.id) ?? [];
1335
1333
 
1336
1334
  for (const collection of threadCollections) {
@@ -16,7 +16,7 @@ import { createEntityId } from "../lib/ids.js";
16
16
  import { now } from "../lib/time.js";
17
17
  import { ConflictError } from "../lib/errors.js";
18
18
  import { normalizePath } from "../lib/url.js";
19
- import type { PathKind, PathRecord } from "../types.js";
19
+ import type { PathKind, PathRecord, Status } from "../types.js";
20
20
 
21
21
  export interface ResolvedPath extends PathRecord {
22
22
  targetType: "post" | "collection" | "redirect" | "archive";
@@ -55,6 +55,7 @@ export interface NavigableItem {
55
55
  path: string;
56
56
  type: "post" | "collection";
57
57
  format?: string;
58
+ status?: Status;
58
59
  }
59
60
 
60
61
  export function toCollectionPath(slug: string): string {
@@ -375,6 +376,7 @@ export function createPathService(
375
376
  .select({
376
377
  title: posts.title,
377
378
  format: posts.format,
379
+ status: posts.status,
378
380
  path: pathRegistry.path,
379
381
  })
380
382
  .from(pathRegistry)
@@ -422,6 +424,7 @@ export function createPathService(
422
424
  path: row.path,
423
425
  type: "post",
424
426
  format: row.format,
427
+ status: row.status,
425
428
  });
426
429
  }
427
430