fedipod 0.8.0

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 (276) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/architecture.svg +89 -0
  4. package/bin/fedipod.mjs +2303 -0
  5. package/cli.md +243 -0
  6. package/groups.md +85 -0
  7. package/gui.md +99 -0
  8. package/lib/account.mjs +87 -0
  9. package/lib/admin.mjs +1606 -0
  10. package/lib/atproto.mjs +310 -0
  11. package/lib/bskyfeed.mjs +166 -0
  12. package/lib/bskygroup.mjs +162 -0
  13. package/lib/c2s.mjs +305 -0
  14. package/lib/certs.mjs +265 -0
  15. package/lib/deliver.mjs +249 -0
  16. package/lib/directory.mjs +90 -0
  17. package/lib/embed.mjs +214 -0
  18. package/lib/export-collections.mjs +161 -0
  19. package/lib/front-core.mjs +322 -0
  20. package/lib/gateway-core.mjs +115 -0
  21. package/lib/guard.mjs +213 -0
  22. package/lib/home.mjs +161 -0
  23. package/lib/httpsig.mjs +109 -0
  24. package/lib/import.mjs +357 -0
  25. package/lib/intake.mjs +1710 -0
  26. package/lib/keys.mjs +139 -0
  27. package/lib/lease.mjs +190 -0
  28. package/lib/localapi.mjs +87 -0
  29. package/lib/mastoapi.mjs +1588 -0
  30. package/lib/migrate.mjs +127 -0
  31. package/lib/oidc-auth.mjs +53 -0
  32. package/lib/podrdf.mjs +132 -0
  33. package/lib/ports.mjs +27 -0
  34. package/lib/publisher.mjs +1003 -0
  35. package/lib/remote.mjs +303 -0
  36. package/lib/safefetch.mjs +235 -0
  37. package/lib/setup.mjs +262 -0
  38. package/lib/social.mjs +505 -0
  39. package/lib/storage.mjs +170 -0
  40. package/lib/store.mjs +470 -0
  41. package/lib/streaming.mjs +154 -0
  42. package/lib/tagfeed.mjs +158 -0
  43. package/lib/ua.mjs +29 -0
  44. package/lib/update.mjs +91 -0
  45. package/lib/webpush.mjs +98 -0
  46. package/lib/wire.mjs +762 -0
  47. package/package.json +60 -0
  48. package/phanpy/dist/404.html +40 -0
  49. package/phanpy/dist/_headers +2 -0
  50. package/phanpy/dist/apple-touch-icon.png +0 -0
  51. package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
  52. package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
  53. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  54. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  55. package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  56. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
  57. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
  58. package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  59. package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
  60. package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
  61. package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
  62. package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
  63. package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
  64. package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
  65. package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
  66. package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
  67. package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
  68. package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
  69. package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  70. package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  71. package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  72. package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  73. package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  74. package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
  75. package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  76. package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  77. package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  78. package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  79. package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  80. package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  81. package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  82. package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  83. package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  84. package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  85. package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
  86. package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
  87. package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  88. package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
  89. package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  90. package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  91. package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  92. package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  93. package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
  94. package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  95. package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
  96. package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  97. package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  98. package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  99. package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  100. package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  101. package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  102. package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  103. package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
  104. package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
  105. package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
  106. package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  107. package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  108. package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  109. package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  110. package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
  111. package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
  112. package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  113. package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  114. package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
  115. package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  116. package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  117. package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  118. package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  119. package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
  120. package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  121. package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  122. package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
  123. package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
  124. package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
  125. package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  126. package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  127. package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  128. package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
  129. package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  130. package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
  131. package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  132. package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
  133. package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  134. package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  135. package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  136. package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
  137. package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
  138. package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  139. package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
  140. package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
  141. package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
  142. package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
  143. package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  144. package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  145. package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  146. package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  147. package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  148. package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
  149. package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
  150. package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  151. package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  152. package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  153. package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
  154. package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
  155. package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  156. package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
  157. package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
  158. package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
  159. package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
  160. package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
  161. package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
  162. package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
  163. package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
  164. package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
  165. package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  166. package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  167. package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  168. package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  169. package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  170. package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
  171. package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  172. package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
  173. package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  174. package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  175. package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
  176. package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  177. package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  178. package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  179. package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  180. package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  181. package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  182. package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  183. package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
  184. package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  185. package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
  186. package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
  187. package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  188. package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  189. package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
  190. package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
  191. package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  192. package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  193. package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
  194. package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  195. package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  196. package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
  197. package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
  198. package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
  199. package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  200. package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  201. package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
  202. package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
  203. package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
  204. package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  205. package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
  206. package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  207. package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  208. package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
  209. package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  210. package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
  211. package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  212. package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  213. package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  214. package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  215. package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  216. package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
  217. package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
  218. package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
  219. package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  220. package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
  221. package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
  222. package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
  223. package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
  224. package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
  225. package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
  226. package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
  227. package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  228. package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  229. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
  230. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  231. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
  232. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  233. package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
  234. package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
  235. package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
  236. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  237. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  238. package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
  239. package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
  240. package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
  241. package/phanpy/dist/compose/index.html +20 -0
  242. package/phanpy/dist/favicon.ico +0 -0
  243. package/phanpy/dist/index.html +73 -0
  244. package/phanpy/dist/logo-192.png +0 -0
  245. package/phanpy/dist/logo-512.png +0 -0
  246. package/phanpy/dist/logo-badge-72.png +0 -0
  247. package/phanpy/dist/logo-maskable-512.png +0 -0
  248. package/phanpy/dist/logo-monochrome-512.png +0 -0
  249. package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
  250. package/phanpy/dist/manifest.webmanifest +1 -0
  251. package/phanpy/dist/og-image-2.jpg +0 -0
  252. package/phanpy/dist/og-image.png +0 -0
  253. package/phanpy/dist/robots.txt +2 -0
  254. package/phanpy/dist/sw.js +25 -0
  255. package/phanpy/dist/version.json +1 -0
  256. package/run-agent.mjs +805 -0
  257. package/scripts/build-dist.mjs +37 -0
  258. package/scripts/verify-ui.mjs +60 -0
  259. package/ui/README.md +23 -0
  260. package/vendor/gate.cjs +132 -0
  261. package/vendor/idp-grant.cjs +394 -0
  262. package/vendor/jfetch.cjs +16 -0
  263. package/web/admin/admin.js +958 -0
  264. package/web/admin/bar.css +64 -0
  265. package/web/admin/bar.js +61 -0
  266. package/web/admin/client/client.js +66 -0
  267. package/web/admin/client/index.html +42 -0
  268. package/web/admin/index.html +493 -0
  269. package/web/admin/setup/index.html +172 -0
  270. package/web/admin/setup/setup.js +268 -0
  271. package/web/admin/window.css +55 -0
  272. package/web/admin/window.js +136 -0
  273. package/web/front/install.sh +69 -0
  274. package/web/front/new-account.html +424 -0
  275. package/web/front/run.html +138 -0
  276. package/web/front/solid-client-authn.bundle.js +2 -0
@@ -0,0 +1,958 @@
1
+ // admin.js — the record, as a page. Everything here is an existing admin
2
+ // route; the CLI's own group commands are thin clients over the same ones.
3
+ //
4
+ // Two rules the routes enforce and this page respects: a write is a MERGE, so a
5
+ // form that never mentions a field cannot delete it; and anything on the wire is
6
+ // not real until the actor is republished, which POST /config does for itself.
7
+ //
8
+ // The UI password is deliberately not here — `fedipod passwd` sets it. It
9
+ // only gates /oauth/authorize, so it does nothing for a loopback-only agent.
10
+
11
+ const $ = (id) => document.getElementById(id);
12
+ // The door this page is served behind: nothing when the agent runs on this
13
+ // machine, `/app` when the identity is hosted by its own pod server. Read from
14
+ // the page's own address, so one build serves both.
15
+ const BASE = location.pathname.replace(/\/admin\/.*$/u, '');
16
+ const api = async (path, init) => {
17
+ const res = await fetch(BASE + path, init);
18
+ return { status: res.status, json: await res.json().catch(() => null) };
19
+ };
20
+ const postJson = (path, body) => api(path, {
21
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
22
+ });
23
+
24
+ let config = null;
25
+ // Held, because render() moves it into a generated row and empties that row's
26
+ // list on the next pass — after which getElementById would not find it again.
27
+ const MODERATION = $('moderation');
28
+ const STATUS = $('status-ctl');
29
+ const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid-render
30
+ const BSKY_CTL = $('bsky-ctl');
31
+ const BSKY_CONNECT_CTL = $('bsky-connect-ctl');
32
+ const FOLLOWS_CTL = $('follows-ctl');
33
+ const FOLLOWS_PICK = $('follows-pick');
34
+ const UPDATE_CTL = $('update-ctl');
35
+ const UPDATE_WORD = $('update-word');
36
+ const UPDATE_GO = $('update-go');
37
+ const BSKY_CROSSPOST = $('bsky-crosspost'); // held: it rides BSKY_CTL into a generated row
38
+
39
+ // #say and #fatal live in the accessibility tree from load and hide by being
40
+ // empty (see the stylesheet). Unhiding a live region and filling it in the same
41
+ // task is the classic way to get no announcement at all.
42
+ function say(text, cls = 'ok') {
43
+ const el = $('say');
44
+ el.className = cls;
45
+ // Clear first, then set on the next task: a live region that is handed the
46
+ // same text it already holds announces nothing, so two identical messages in
47
+ // a row ("nothing changed", "nothing changed") would be silent the second time.
48
+ el.textContent = '';
49
+ setTimeout(() => { el.textContent = text; }, 30);
50
+ }
51
+
52
+ // Every write goes through here, so nothing silently half-succeeds.
53
+ async function write(path, body, done) {
54
+ const { status, json } = await postJson(path, body);
55
+ if (status >= 400) { say(json?.error || `refused (HTTP ${status})`, 'err'); return null; }
56
+ say(done);
57
+ return json;
58
+ }
59
+
60
+ UPDATE_GO.onclick = async () => {
61
+ UPDATE_GO.disabled = true;
62
+ const r = await write('/update', {}, 'updating — the agents restart when it finishes; reload in a moment');
63
+ if (!r) UPDATE_GO.disabled = false;
64
+ };
65
+
66
+ async function load() {
67
+ const { status, json } = await api('/config');
68
+ if (status === 409) {
69
+ $('fatal').textContent = 'This agent has no identity yet.';
70
+ const a = document.createElement('a');
71
+ a.href = '/admin/setup/';
72
+ a.textContent = ' Set it up.';
73
+ $('fatal').appendChild(a);
74
+ return;
75
+ }
76
+ if (status !== 200 || !json) {
77
+ $('fatal').textContent = json?.error || `could not read the record (HTTP ${status})`;
78
+ return;
79
+ }
80
+ config = json;
81
+ render();
82
+ if (new URLSearchParams(location.search).has('new')) openNewActor();
83
+ }
84
+
85
+ // Every disclosure on the page — a form, the log, a lifecycle confirmation —
86
+ // is a panel declared inside the floating window and shown by window.js. It
87
+ // used to be an accordion at the foot of the page, which pushed whatever you
88
+ // were reading out from under you and could only ever show one thing.
89
+ //
90
+ // The reset lives here rather than in window.js: the window knows how to show a
91
+ // panel, not what any of them mean.
92
+ function resetConfirm() {
93
+ pending = null;
94
+ for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
95
+ $('confirm-handle').value = '';
96
+ $('confirm-handle-move').value = '';
97
+ $('move-target').value = '';
98
+ $('state-path').value = '';
99
+ $('state-url').value = '';
100
+ document.querySelector('input[name=stateWhere][value=path]').checked = true;
101
+ stateRows();
102
+ }
103
+ function closePanels(keep = null) {
104
+ if (!keep) solWindow.close();
105
+ if (keep !== 'output') outputSource = null;
106
+ if (keep !== 'confirm-form') resetConfirm();
107
+ }
108
+ // Closing it by the ✕ or by Escape has to clear the same state a Cancel does.
109
+ document.getElementById('win').addEventListener('win:closed', () => {
110
+ outputSource = null;
111
+ resetConfirm();
112
+ $('bsky-password').value = ''; // never leave a secret in a closed panel
113
+ });
114
+
115
+
116
+ function render() {
117
+ // The bar names the actor, the same way on every page. Only the tab title is
118
+ // this page's own business.
119
+ document.title = `FediPod — ${config.handle}`;
120
+ const facts = $('facts');
121
+ facts.textContent = '';
122
+ // What this actor IS comes first. The handle is already in the address beside
123
+ // the heading; the pod, the issuer and the actor URL all read off the WebID;
124
+ // and where the private half sits is not something you act on from here.
125
+ const origins = config.origins || {};
126
+ // Two of these are addresses of things you can open, so they are links. The
127
+ // fediverse one goes to this actor's own page in the client, same origin, so
128
+ // it stays in the tab. The Solid one leaves for the pod, so it does not.
129
+ const rows = [
130
+ ['kind', config.kind ? config.kind[0].toUpperCase() + config.kind.slice(1) : config.kind],
131
+ // The value is the select itself — what it shows IS the state, and
132
+ // changing the word is the whole action, like the moderation controls.
133
+ ['status', 'ctl'],
134
+ ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
135
+ config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
136
+ ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
137
+ ['Bluesky identity', 'ctl'],
138
+ ['local store', config.home],
139
+ // The address you actually open, not the bare number — the named origin when
140
+ // there is one, since that is what the client and the OAuth redirect use.
141
+ ['local host', (origins.named || origins.loopback || `http://localhost:${config.port}`)
142
+ .replace(/\/$/, '')],
143
+ ];
144
+ if (config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
145
+ if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
146
+ if (config.movedTo) rows.push(['moved to', config.movedTo]);
147
+ // A person gates followers here; a group's gate is the joins control on its
148
+ // kind row, so the row would be a second switch for the same thing.
149
+ if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl']);
150
+ for (const [k, v, link] of rows) {
151
+ if (!v) continue;
152
+ const dt = document.createElement('dt');
153
+ dt.textContent = k;
154
+ const dd = document.createElement('dd');
155
+ if (link) {
156
+ const a = document.createElement('a');
157
+ a.href = link.href;
158
+ a.textContent = v;
159
+ if (link.blank) {
160
+ a.target = '_blank';
161
+ // noopener because the pod is another origin: without it the page we
162
+ // open gets a handle on this one through window.opener.
163
+ a.rel = 'noopener';
164
+ a.title = ` Open ${link.href} in a new tab`;
165
+ } else {
166
+ a.title = ` This actor's page in our client`;
167
+ }
168
+ dd.appendChild(a);
169
+ } else {
170
+ dd.textContent = v;
171
+ }
172
+ // What a group can be moderated into is a property of BEING a group, so the
173
+ // controls sit on the row that says so. Moved rather than built here: the
174
+ // page still declares them, and `facts` is emptied on every render.
175
+ if (k === 'kind' && config.kind === 'group') dd.append(MODERATION);
176
+ if (k === 'status') {
177
+ dd.textContent = '';
178
+ dd.append(STATUS);
179
+ STATUS.hidden = false;
180
+ renderStatus();
181
+ }
182
+ if (k === 'new followers') {
183
+ dd.textContent = '';
184
+ dd.append(FOLLOWS_CTL);
185
+ FOLLOWS_CTL.hidden = false;
186
+ FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
187
+ }
188
+ if (k === 'software') {
189
+ dd.textContent = '';
190
+ dd.append(UPDATE_CTL);
191
+ UPDATE_CTL.hidden = false;
192
+ const u = config.update;
193
+ const words = [];
194
+ if (u) words.push(u.available ? `FediPod ${u.current} — ${u.latest} available` : `FediPod ${u.current}`);
195
+ if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
196
+ UPDATE_WORD.textContent = words.join('; ');
197
+ UPDATE_GO.hidden = !u?.available;
198
+ }
199
+ // Connected: the account itself, then the way out. Not: a form on the page.
200
+ if (k === 'Bluesky identity') {
201
+ dd.textContent = '';
202
+ if (config.atproto?.connected) {
203
+ const a = document.createElement('a');
204
+ a.href = `https://bsky.app/profile/${config.atproto.handle}`;
205
+ a.textContent = `@${config.atproto.handle}`;
206
+ a.target = '_blank';
207
+ a.rel = 'noopener';
208
+ a.title = ` Open this account's Bluesky page in a new tab`;
209
+ dd.append(a, ' ', BSKY_CTL);
210
+ BSKY_CTL.hidden = false;
211
+ BSKY_CONNECT_CTL.hidden = true;
212
+ BSKY_CROSSPOST.value = config.atproto.crossPost ? 'on' : 'off';
213
+ } else {
214
+ dd.append(BSKY_CONNECT_CTL);
215
+ BSKY_CONNECT_CTL.hidden = false;
216
+ BSKY_CTL.hidden = true;
217
+ }
218
+ }
219
+ facts.append(dt, dd);
220
+ }
221
+ if (!config.address) {
222
+ const p = document.createElement('p');
223
+ p.className = 'warn';
224
+ p.textContent = `${config.remotePod} is not the root of its own host, so this actor cannot be `
225
+ + 'discovered as a handle by other servers. Posting and reading still work.';
226
+ $('pane-identity').appendChild(p);
227
+ }
228
+
229
+ // pane-others carries the create control too, so it appears even when this is
230
+ // the only actor and even if /profiles cannot be read.
231
+ for (const id of ['pane-others', 'pane-identity', 'rail']) $(id).hidden = false;
232
+ renderAliases();
233
+ renderOthers();
234
+ renderInbox();
235
+ if (config.kind === 'group') {
236
+ // Its lists have no bound, so this page scrolls — see body.group in the CSS.
237
+ document.body.classList.add('group');
238
+ $('pane-group').hidden = false;
239
+ MODERATION.hidden = false; // only a group has any
240
+ renderGroupToggles();
241
+ refreshGroup();
242
+ } else {
243
+ // A person has a follow-request queue too now: nothing binds an inbound
244
+ // Follow to the actor it names, so one that cannot be verified waits here
245
+ // rather than being accepted on the strength of who it claims to be.
246
+ refreshRequests();
247
+ }
248
+ }
249
+
250
+ // ---- the actors running on this machine ----
251
+ // Somewhere to go, and nothing else. A stopped actor is left out — the link
252
+ // would land on nothing. The one you are on keeps its place in the row but
253
+ // offers only `app`, because its admin page is the page you are reading.
254
+
255
+ let actors = [];
256
+
257
+ async function renderOthers() {
258
+ const { json } = await api('/profiles');
259
+ actors = json?.identities || [];
260
+ const sel = $('actor-pick');
261
+ sel.textContent = '';
262
+ // The fediverse address, which is what the actor IS to everyone else, and the
263
+ // only form that stays distinct: two identities can share a local handle, but
264
+ // never a handle AND a pod. A stopped one has no address to report — nothing
265
+ // answered — so it falls back to the name it is filed under.
266
+ const label = (r) => (r.address || r.handle || r.name)
267
+ + (r.mode && r.mode !== 'active' ? ` (${r.mode})` : '') + (r.mode ? '' : ' (stopped)');
268
+ const seen = {};
269
+ for (const r of actors) seen[label(r)] = (seen[label(r)] || 0) + 1;
270
+
271
+ for (const [i, r] of actors.entries()) {
272
+ const o = document.createElement('option');
273
+ o.value = String(i);
274
+ o.selected = !!r.current; // it opens showing where you already are
275
+ o.textContent = label(r) + (seen[label(r)] > 1 && r.port ? ` :${r.port}` : '');
276
+ sel.appendChild(o);
277
+ }
278
+ // The way to a NEW actor rides the same dropdown as the existing ones.
279
+ const add = document.createElement('option');
280
+ add.value = '__add';
281
+ add.textContent = '+ add a new account…';
282
+ sel.appendChild(add);
283
+ }
284
+
285
+ // Going to an actor commits a navigation and, for a stopped one, a POST that
286
+ // boots its pod — so it must run once per choice. Pressing Enter on the select
287
+ // fires BOTH a keydown (handled below) and a native `change`, so without a
288
+ // guard a keyboard commit would start the actor twice.
289
+ let goingToActor = false;
290
+ const goToActor = async () => {
291
+ if (goingToActor) return;
292
+ if ($('actor-pick').value === '__add') { renderOthers(); openNewActor(); return; }
293
+ const r = actors[Number($('actor-pick').value)];
294
+ if (!r || r.current) return;
295
+ goingToActor = true;
296
+ try {
297
+ if (r.mode) { location.href = r.admin; return; }
298
+ $('actor-pick').disabled = true;
299
+ say(`starting ${r.name}`);
300
+ const started = await write('/start-actor', { name: r.name }, `${r.name} is up`);
301
+ $('actor-pick').disabled = false;
302
+ if (started?.url) location.href = `${started.url}admin/`;
303
+ else renderOthers(); // put the picker back on the current actor
304
+ } finally {
305
+ goingToActor = false;
306
+ }
307
+ };
308
+ // Choosing one goes to its record, as it always has.
309
+ //
310
+ // Arrowing is the one case that must not: on a closed select every Arrow
311
+ // keypress fires `change`, so a keyboard user browsing the list would be
312
+ // carried off to the first actor they passed — and for a stopped one that
313
+ // boots its pod. An arrow key arms a flag and the change it causes is ignored;
314
+ // Enter commits. A mouse never sets the flag, so clicking behaves as before.
315
+ let arrowing = false;
316
+ $('actor-pick').addEventListener('keydown', (ev) => {
317
+ if (['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(ev.key)) arrowing = true;
318
+ if (ev.key === 'Enter') { arrowing = false; goToActor(); }
319
+ });
320
+ $('actor-pick').addEventListener('pointerdown', () => { arrowing = false; });
321
+ $('actor-pick').addEventListener('change', () => {
322
+ if (!arrowing) goToActor();
323
+ arrowing = false;
324
+ });
325
+
326
+ // Setting up a new actor asks for exactly what this page cannot already tell
327
+ // it: the pod and the account behind it, and its permanent name. Display name,
328
+ // bio and pictures are the client's job now — Phanpy's profile editor writes
329
+ // them through /api/v1/accounts/update_credentials. Where the private data goes
330
+ // is not a question either: it lands beside the credential, and `state --to`
331
+ // moves it afterwards.
332
+
333
+ const picked = (name) => document.querySelector(`input[name=${name}]:checked`)?.value;
334
+
335
+ function newActorRows() {
336
+ const mode = picked('newMode');
337
+ $('new-row-podname').hidden = mode !== 'new';
338
+ $('new-row-pod').hidden = mode !== 'existing';
339
+ }
340
+ for (const el of document.querySelectorAll('input[name=newMode]')) {
341
+ el.addEventListener('change', newActorRows);
342
+ }
343
+
344
+ // While the form is up the page IS the new actor's setup — so the record of
345
+ // the actor you came from goes away, list included. Which panes were showing
346
+ // is remembered rather than recomputed: whether Group and Inbox belong is a
347
+ // decision render() already made.
348
+ const RECORD_PANES = ['pane-identity', 'pane-group', 'pane-inbox', 'rail'];
349
+ let putBack = [];
350
+
351
+ function showNewActor(on) {
352
+ if (on) closePanels(); // nothing of the old actor left open behind it
353
+ document.body.classList.toggle('adding', on); // see body.adding in the CSS
354
+ $('new-actor-form').hidden = !on;
355
+ if (on) {
356
+ putBack = RECORD_PANES.filter(id => !$(id).hidden);
357
+ for (const id of putBack) $(id).hidden = true;
358
+ } else {
359
+ for (const id of putBack) $(id).hidden = false;
360
+ putBack = [];
361
+ }
362
+ }
363
+
364
+ // `add a new account` is the last item of the Local Actors dropdown; choosing
365
+ // it opens the form in place. Arriving with ?new does the same.
366
+ function openNewActor() {
367
+ showNewActor(true);
368
+ newActorRows();
369
+ $('new-handle').focus();
370
+ }
371
+ $('new-actor-cancel').addEventListener('click', () => { showNewActor(false); say('nothing changed'); });
372
+
373
+ $('new-actor-form').addEventListener('submit', async (ev) => {
374
+ ev.preventDefault();
375
+ const handle = $('new-handle').value.trim();
376
+ const answers = {
377
+ handle,
378
+ kind: picked('newKind'),
379
+ mode: picked('newMode'),
380
+ issuer: $('new-issuer').value.trim(),
381
+ email: $('new-email').value.trim(),
382
+ password: $('new-password').value,
383
+ };
384
+ if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
385
+ else answers.pod = $('new-pod').value.trim();
386
+ $('new-actor-go').disabled = true;
387
+ say(`setting up ${handle || 'the new actor'} — this takes a while`);
388
+ const r = await write('/new-actor', answers, `setting up ${handle}`);
389
+ $('new-actor-go').disabled = false;
390
+ if (r?.url) location.href = r.url; // its own page, where the progress is
391
+ });
392
+
393
+ // ---- bluesky ----
394
+
395
+ // The connect form lives in the floating window, like every other form here.
396
+ // The inbound transfer panel: the aliases live here now, not in the facts.
397
+ $('do-transfer-in').addEventListener('click', () => {
398
+ solWindow.show('transfer-in-form', 'Transfer an account here');
399
+ });
400
+
401
+ // The rail: one click hides the actions; on a phone it starts hidden.
402
+ function setRail(closed) {
403
+ $('rail').classList.toggle('closed', closed);
404
+ const t = $('rail-toggle');
405
+ t.setAttribute('aria-expanded', String(!closed));
406
+ t.querySelector('.rail-arrow').textContent = closed ? '»' : '«';
407
+ t.title = closed ? ' Show the action panel' : ' Hide the action panel';
408
+ }
409
+ $('rail-toggle').addEventListener('click', () => setRail(!$('rail').classList.contains('closed')));
410
+ if (matchMedia('(max-width: 47rem)').matches) setRail(true);
411
+
412
+ $('bsky-open').addEventListener('click', () => {
413
+ closePanels();
414
+ solWindow.show('bsky-form', 'Connect Bluesky account');
415
+ });
416
+
417
+ let bskyBusy = false;
418
+ $('bsky-form').addEventListener('submit', async (ev) => {
419
+ ev.preventDefault();
420
+ if (bskyBusy) return;
421
+ bskyBusy = true;
422
+ try {
423
+ const r = await write('/atproto/connect', {
424
+ service: $('bsky-service').value.trim(),
425
+ identifier: $('bsky-identifier').value.trim(),
426
+ appPassword: $('bsky-password').value,
427
+ }, 'bluesky account connected');
428
+ if (r) {
429
+ $('bsky-password').value = '';
430
+ closePanels();
431
+ await load();
432
+ }
433
+ } finally { bskyBusy = false; }
434
+ });
435
+ $('bsky-disconnect').addEventListener('click', async () => {
436
+ if (bskyBusy) return;
437
+ bskyBusy = true;
438
+ try {
439
+ if (await write('/atproto/disconnect', {}, 'bluesky account disconnected')) await load();
440
+ } finally { bskyBusy = false; }
441
+ });
442
+ // Adding resolves the old account on the agent side, so what lands in
443
+ // alsoKnownAs is its canonical id, not the string typed here.
444
+ // The accounts elsewhere this one may receive a Move from — the chips in the
445
+ // Transfer-an-account-here panel. Each entry is removable, behind a second
446
+ // click: servers still retrying a Move check the list, so removal is not a
447
+ // tidy-up.
448
+ function renderAliases() {
449
+ const box = $('alias-chips');
450
+ box.textContent = '';
451
+ for (const alias of config.aliases || []) {
452
+ let armed = false;
453
+ const rm = document.createElement('button');
454
+ rm.type = 'button';
455
+ rm.className = 'inline danger';
456
+ rm.textContent = '✕';
457
+ rm.title = ' Remove this alias';
458
+ rm.addEventListener('click', async () => {
459
+ if (!armed) {
460
+ armed = true;
461
+ rm.textContent = 'confirm ✕';
462
+ rm.title = ' Servers still retrying the Move check this alias — click again to remove it anyway';
463
+ return;
464
+ }
465
+ const r = await write('/alias', { remove: alias, confirm: true }, 'alias removed and the actor republished');
466
+ if (r) { config.aliases = r.aliases; render(); }
467
+ });
468
+ const chip = document.createElement('span');
469
+ chip.append(alias, ' ', rm);
470
+ box.append(chip, ' ');
471
+ }
472
+ }
473
+
474
+ let aliasBusy = false;
475
+ $('alias-add').addEventListener('click', async () => {
476
+ if (aliasBusy) return;
477
+ const v = $('alias-input').value.trim();
478
+ if (!v) { say('enter the old account first — @you@old.server', 'err'); return; }
479
+ aliasBusy = true;
480
+ try {
481
+ const r = await write('/alias', { add: v }, 'alias added and the actor republished');
482
+ if (r) { $('alias-input').value = ''; config.aliases = r.aliases; render(); }
483
+ } finally { aliasBusy = false; }
484
+ });
485
+
486
+ // Silent on success — the select already shows the new state.
487
+ const setCrossPost = async (on) => {
488
+ if (bskyBusy || on === !!config.atproto?.crossPost) return;
489
+ bskyBusy = true;
490
+ try {
491
+ if (await write('/atproto', { crossPost: on }, '')) config.atproto.crossPost = on;
492
+ BSKY_CROSSPOST.value = config.atproto?.crossPost ? 'on' : 'off';
493
+ } finally { bskyBusy = false; }
494
+ };
495
+
496
+ // ---- group ----
497
+
498
+ // Each control shows the setting it would change, so what it displays IS the
499
+ // current state — no separate sentence reporting it.
500
+ const MOD = { on: 'moderated', off: 'unmoderated' };
501
+ function renderGroupToggles() {
502
+ $('joins-mod').value = config.approveJoins ? MOD.on : MOD.off;
503
+ $('review-mod').value = config.review ? MOD.on : MOD.off;
504
+ }
505
+ function renderStatus() {
506
+ STATUS_PICK.value = config.quiescedAt ? 'parked' : 'active';
507
+ }
508
+
509
+ // Parking is reversible — the follow graph is saved first and going back to
510
+ // active re-sends a Follow to everyone in it — so the word applies directly,
511
+ // like the moderation controls. The result line says what actually happened.
512
+ let statusBusy = false;
513
+ const setStatus = async (parked) => {
514
+ if (statusBusy || parked === !!config.quiescedAt) return;
515
+ statusBusy = true;
516
+ try {
517
+ const r = await write(parked ? '/park' : '/revive', {},
518
+ parked ? 'parking — unfollowing everyone and closing the inbox' : 'reviving — re-following the saved graph');
519
+ if (r) {
520
+ say(parked
521
+ ? `parked ${r.quiescedAt}: unfollowed ${r.unfollowed}/${r.following}, inbox closed`
522
+ : `revived: inbox open, ${r.refollowed}/${r.of} follow(s) re-sent`);
523
+ await load();
524
+ }
525
+ renderStatus();
526
+ } finally { statusBusy = false; }
527
+ };
528
+
529
+ // One write at a time, because committing an open picker with Enter fires our
530
+ // keydown AND the browser's change — the second call must find the first still
531
+ // holding the flag, or config, not yet updated by the awaited write, lets a
532
+ // duplicate POST through.
533
+ let modBusy = false;
534
+ const setJoins = async (approve) => {
535
+ // Picking the value already in force is not a change: no write, and no
536
+ // republish of the actor for a setting that did not move.
537
+ if (modBusy || approve === !!config.approveJoins) return;
538
+ modBusy = true;
539
+ try {
540
+ if (await write('/joins', { approve }, approve ? 'join requests will wait' : 'anyone may join')) {
541
+ config.approveJoins = approve;
542
+ refreshGroup();
543
+ }
544
+ // Both ways: refused, the control must not keep the value it did not get;
545
+ // granted, a blur meanwhile may have repainted it to the old state.
546
+ renderGroupToggles();
547
+ } finally { modBusy = false; }
548
+ };
549
+ const setReview = async (on) => {
550
+ if (modBusy || on === !!config.review) return;
551
+ modBusy = true;
552
+ try {
553
+ if (await write('/review', { on }, on ? 'posts will be held' : 'posts will be carried at once')) {
554
+ config.review = on;
555
+ refreshGroup();
556
+ }
557
+ renderGroupToggles();
558
+ } finally { modBusy = false; }
559
+ };
560
+
561
+ // Arrowing a closed select fires `change` on every keypress, so a keyboard user
562
+ // running down the list would apply each value they passed — here that silently
563
+ // opens or closes the group. An arrow arms a flag and the change it causes is
564
+ // ignored; Enter commits. A mouse never sets the flag, so clicking is unchanged.
565
+ // Same guard as the actor picker above, for the same reason.
566
+ function onPick(el, apply, repaint = renderGroupToggles) {
567
+ let arrowing = false;
568
+ el.addEventListener('keydown', (ev) => {
569
+ if (['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(ev.key)) arrowing = true;
570
+ if (ev.key === 'Enter') { arrowing = false; apply(el.value); }
571
+ // Escape abandons the armed value; what shows must return to what is.
572
+ if (ev.key === 'Escape') { arrowing = false; repaint(); }
573
+ });
574
+ // Leaving abandons too. Unconditional, because the swallow above already
575
+ // consumed the flag — the select may show a state that was never applied,
576
+ // and repainting from config costs nothing when it does match.
577
+ el.addEventListener('blur', () => { arrowing = false; repaint(); });
578
+ el.addEventListener('pointerdown', () => { arrowing = false; });
579
+ el.addEventListener('change', () => {
580
+ if (!arrowing) apply(el.value);
581
+ arrowing = false;
582
+ });
583
+ }
584
+
585
+ onPick($('joins-mod'), (v) => setJoins(v === MOD.on));
586
+ onPick($('review-mod'), (v) => setReview(v === MOD.on));
587
+ onPick(STATUS_PICK, (v) => setStatus(v === 'parked'), renderStatus);
588
+ onPick(BSKY_CROSSPOST, (v) => setCrossPost(v === 'on'),
589
+ () => { BSKY_CROSSPOST.value = config?.atproto?.crossPost ? 'on' : 'off'; });
590
+
591
+ // A person's follower gate. Automatic is what a migration wave needs: every
592
+ // follower's server re-follows at once, and the waiting queue caps at 500.
593
+ let followsBusy = false;
594
+ const setAutoAccept = async (auto) => {
595
+ if (followsBusy || auto === !!config.autoAcceptFollows) return;
596
+ followsBusy = true;
597
+ try {
598
+ if (await write('/config', { autoAcceptFollows: auto },
599
+ auto ? 'new followers are accepted automatically' : 'new followers will wait for you')) {
600
+ config.autoAcceptFollows = auto;
601
+ }
602
+ FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
603
+ } finally { followsBusy = false; }
604
+ };
605
+ onPick(FOLLOWS_PICK, (v) => setAutoAccept(v === 'auto'),
606
+ () => { FOLLOWS_PICK.value = config?.autoAcceptFollows ? 'auto' : 'approve'; });
607
+
608
+ // One row: what it is, then what can be done to it.
609
+ function row(text, sub, actions) {
610
+ const li = document.createElement('li');
611
+ const span = document.createElement('span');
612
+ span.textContent = text;
613
+ if (sub) {
614
+ const small = document.createElement('div');
615
+ small.className = 'muted';
616
+ small.textContent = sub;
617
+ span.appendChild(small);
618
+ }
619
+ li.appendChild(span);
620
+ for (const [label, run, hint] of actions) {
621
+ const b = document.createElement('button');
622
+ b.className = 'inline'; // sized like the page's other buttons
623
+ b.textContent = label;
624
+ // A list of fifteen buttons all reading "Mute" tells a screen-reader user
625
+ // browsing by button nothing; name each one for the row it acts on.
626
+ b.setAttribute('aria-label', `${label} ${text}`);
627
+ // Three leading spaces: the tooltip appears under the pointer, and without
628
+ // them the first word sits behind the cursor.
629
+ if (hint) b.title = ` ${hint}`;
630
+ // Where focus should land afterwards. `b.disabled = true` blurs it to
631
+ // <body> at once, and refreshGroup() then wipes and rebuilds the list, so
632
+ // without this every mute, eject, admit or refuse dropped a keyboard user
633
+ // back to the top of the page.
634
+ b.addEventListener('click', async () => {
635
+ const list = li.parentNode;
636
+ focusAfterRefresh = { listId: list?.id, index: [...(list?.children || [])].indexOf(li), label };
637
+ b.disabled = true;
638
+ await run();
639
+ refreshGroup();
640
+ });
641
+ li.appendChild(b);
642
+ }
643
+ return li;
644
+ }
645
+
646
+ // Set by a row action, consumed by the fill() that replaces that row.
647
+ let focusAfterRefresh = null;
648
+
649
+ function fill(listId, countId, items, make) {
650
+ const ul = $(listId);
651
+ ul.textContent = '';
652
+ $(countId).textContent = items.length ? `(${items.length})` : '(none)';
653
+ for (const it of items) ul.appendChild(make(it));
654
+ if (!focusAfterRefresh || focusAfterRefresh.listId !== listId) return;
655
+ const { index, label } = focusAfterRefresh;
656
+ focusAfterRefresh = null;
657
+ // The same action on the row that took this one's place, or the row before it
658
+ // when the list just got shorter. Nothing left to land on is the one case
659
+ // where the heading is the honest answer.
660
+ const rows = [...ul.children];
661
+ const li = rows[Math.min(index, rows.length - 1)];
662
+ const same = li && [...li.querySelectorAll('button')].find(x => x.textContent === label);
663
+ const heading = $(countId).closest('h3');
664
+ // A bare <h3> is not focusable, so the "nothing left" fallback would silently
665
+ // drop focus to <body> — give it a programmatic-only tab stop first.
666
+ if (heading && !heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1');
667
+ (same || li?.querySelector('button') || heading)?.focus?.();
668
+ }
669
+
670
+ // The request rows, shared: a group calls them joins, a person calls them
671
+ // follows, and the two do exactly the same thing to exactly the same queue.
672
+ function fillRequests(list) {
673
+ fill('requests', 'requests-count', list, (r) => row(r.actor, r.at, [
674
+ ['Accept', () => write('/admit', { actor: r.actor }, 'accepted'), 'Let them follow you'],
675
+ ['Refuse', () => write('/refuse', { actor: r.actor }, 'refused'), 'Turn this down; they may ask again'],
676
+ ]));
677
+ }
678
+
679
+ // The whole queue in one action — the shape a migration wave arrives in.
680
+ $('admit-all').addEventListener('click', async () => {
681
+ if (await write('/admit', { all: true }, 'accepted everyone waiting')) {
682
+ if (config.kind === 'group') refreshGroup(); else refreshRequests();
683
+ }
684
+ });
685
+
686
+ // A person's whole group pane is this one block, and only when it has something
687
+ // in it — an empty heading promising a list is what the group console avoids too.
688
+ async function refreshRequests() {
689
+ const { json } = await api('/requests');
690
+ const list = json?.requests || [];
691
+ $('pane-group').hidden = !list.length;
692
+ $('block-requests').hidden = !list.length;
693
+ if (list.length) fillRequests(list);
694
+ }
695
+
696
+ // What a queued moderation would do, in the words the buttons beside it use.
697
+ function describeModeration(e) {
698
+ const o = e.activity?.object;
699
+ const id = typeof o === 'string' ? o : o?.id || '';
700
+ if (e.type === 'Block') return `block ${id}`;
701
+ if (e.type === 'Remove') return `eject ${id}`;
702
+ if (e.type === 'Delete') return `take down ${id}`;
703
+ if (e.type === 'Add') return `add ${id}`;
704
+ return id || 'no target named';
705
+ }
706
+
707
+ async function refreshGroup() {
708
+ const [members, requests, pending, announced, modqueue] = await Promise.all(
709
+ ['/members', '/requests', '/pending', '/announced', '/modqueue']
710
+ .map(p => api(p).then(r => r.json || {})));
711
+
712
+ fill('requests', 'requests-count', requests.requests || [], (r) => row(r.actor, r.at, [
713
+ ['Admit', () => write('/admit', { actor: r.actor }, 'admitted'), 'Let them in — they become a member'],
714
+ ['Refuse', () => write('/refuse', { actor: r.actor }, 'refused'), 'Turn this request down; they may ask again'],
715
+ ]));
716
+
717
+ fill('pending', 'pending-count', pending.pending || [], (p) => row(p.noteId, `${p.actor} · ${p.at}`, [
718
+ ['Carry it', () => write('/approve', { noteId: p.noteId }, 'carried'), 'Announce this post to every member'],
719
+ ['Decline', () => write('/decline', { noteId: p.noteId }, 'declined'), 'Do not carry it — the post stays up on its author\u2019s pod'],
720
+ ]));
721
+
722
+ // Moderation another server asked for: apply it, or turn it down. The route
723
+ // answers with the list itself, not an object wrapping one.
724
+ const modq = Array.isArray(modqueue) ? modqueue : (modqueue.queue || []);
725
+ fill('modqueue', 'modqueue-count', modq, (e) => row(
726
+ `${e.type} — ${describeModeration(e)}`, `asked by ${e.moderator} · ${e.at}`, [
727
+ ['Carry it out', () => write('/modqueue', { id: e.id, action: 'apply' }, 'applied'),
728
+ 'Do what the moderator asked, as if you had asked it'],
729
+ ['Turn it down', () => write('/modqueue', { id: e.id, action: 'dismiss' }, 'dismissed'),
730
+ 'Leave things as they are and drop the request'],
731
+ ]));
732
+
733
+ // A queue nothing can arrive in is not an empty list, it is a list that does
734
+ // not apply — so the setting has to be on before the heading appears at all.
735
+ // This one has no setting: it fills only when a moderator has asked.
736
+ $('block-requests').hidden = !(config.approveJoins && (requests.requests || []).length);
737
+ $('block-pending').hidden = !(config.review && (pending.pending || []).length);
738
+ $('block-modqueue').hidden = !modq.length;
739
+
740
+ fill('members', 'members-count', members.members || [], (m) => row(
741
+ m.handle || m.actor, m.muted ? 'muted — their posts are not carried' : null,
742
+ [
743
+ m.muted
744
+ ? ['Unmute', () => write('/unmute', { actor: m.actor }, 'unmuted'), 'Carry their posts again']
745
+ : ['Mute', () => write('/mute', { actor: m.actor }, 'muted'), 'Stop carrying their posts; they stay a member'],
746
+ ['Eject', () => write('/eject', { actor: m.actor }, 'ejected'), 'Remove them and tell their server; also mutes'],
747
+ ]));
748
+
749
+ fill('announced', 'announced-count', announced.announced || [], (a) => row(
750
+ a.noteId, `${a.actor} · ${a.announcedAt}`,
751
+ [['Retract', () => write('/retract', { noteId: a.noteId }, 'retracted'), 'Un-say this announcement to everyone it reached']]));
752
+ }
753
+
754
+ // ---- upkeep ----
755
+
756
+ // Which button filled the output pane, so clicking that one again closes it
757
+ // rather than re-fetching the same thing under an already-open panel.
758
+ let outputSource = null;
759
+
760
+ const OUTPUT_TITLES = { log: 'Log', deadletter: 'Dead letters', drain: 'Inbox drain', rebuild: 'Recovered posts' };
761
+ const output = (obj, source = null) => {
762
+ closePanels('output');
763
+ outputSource = source;
764
+ solWindow.show('output', OUTPUT_TITLES[source] || 'Output');
765
+ $('output').textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
766
+ };
767
+
768
+ const showingFrom = (source) => outputSource === source && solWindow.openId() === 'output';
769
+
770
+ // Drain is the one that does work rather than reveals it, so a second click
771
+ // re-runs instead of closing. It can take a while — the agent joins a sweep
772
+ // already in flight and queues another after it — and a button that looks dead
773
+ // for a minute reads as broken.
774
+ $('do-drain').addEventListener('click', async (ev) => {
775
+ const b = ev.currentTarget;
776
+ b.disabled = true;
777
+ b.textContent = 'draining…';
778
+ say('draining the inbox — if a sweep is already running this waits for it to finish');
779
+ const r = await write('/drain', {}, 'inbox drained');
780
+ b.disabled = false;
781
+ b.textContent = 'Drain the inbox';
782
+ if (r) {
783
+ const box = r.inbox || {};
784
+ say(`inbox drained — ${box.count ?? 0} still waiting`);
785
+ output(r, 'drain');
786
+ }
787
+ });
788
+
789
+ // Like the drain, this does work rather than reveals it, so a second click
790
+ // re-runs. Nothing here can lose anything: it only adds posts back.
791
+ $('do-rebuild').addEventListener('click', async (ev) => {
792
+ const b = ev.currentTarget;
793
+ b.disabled = true;
794
+ b.textContent = 'recovering…';
795
+ say('reading what the pod still holds — one request per post, so this takes a moment');
796
+ const r = await write('/rebuild', {}, 'checked');
797
+ b.disabled = false;
798
+ b.textContent = 'Recover posts';
799
+ if (!r) return;
800
+ if (r.why) { say(r.why, 'err'); return; }
801
+ if (!r.landed) { say('recovered posts could NOT be saved — see the log', 'err'); return; }
802
+ say(r.recovered
803
+ ? `recovered ${r.recovered} post(s) the pod had and this machine did not`
804
+ : `nothing was missing — the pod indexed ${r.indexed} post(s), all of them already here`);
805
+ output(r, 'rebuild');
806
+ });
807
+
808
+ $('do-log').addEventListener('click', async () => {
809
+ if (showingFrom('log')) { closePanels(); return; }
810
+ const { json } = await api('/log');
811
+ output((json?.lines || []).slice(-60).join('\n') || 'nothing logged yet', 'log');
812
+ });
813
+
814
+ $('do-deadletter').addEventListener('click', async () => {
815
+ if (showingFrom('deadletter')) { closePanels(); return; }
816
+ const { json } = await api('/deadletter');
817
+ output(json?.items?.length ? json.items : 'no dead letters', 'deadletter');
818
+ });
819
+
820
+ // ---- lifecycle ----
821
+ // None of these can be taken back by clicking again, so the button only opens
822
+ // the matching warning in the markup; the second click is the one that acts.
823
+ // Retire also wants the handle typed, because a misclick cannot produce it.
824
+
825
+ const LIFECYCLE = {
826
+ 'rotate-key': { path: '/rotate-key', title: 'Rotate the signing key', done: (r) => (r.changed ? 'rotated and republished' : 'no change — the key was already fresh') },
827
+ retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
828
+ move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
829
+ done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
830
+ 'move-state': { path: '/state-move', title: 'Move private data', go: 'Move it', focus: 'state-path',
831
+ done: (r) => (r.unchanged ? 'already there — nothing moved'
832
+ : `moved ${r.docs} document(s) and ${r.notes} post(s) — private data is now ${r.now}`) },
833
+ };
834
+
835
+ // The destination choice reveals the field it needs: a directory for this
836
+ // device, an address for another one, nothing for the pod.
837
+ function stateRows() {
838
+ const w = picked('stateWhere');
839
+ $('state-row-path').hidden = w !== 'path';
840
+ $('state-row-url').hidden = w !== 'url';
841
+ }
842
+ for (const el of document.querySelectorAll('input[name=stateWhere]')) {
843
+ el.addEventListener('change', stateRows);
844
+ }
845
+ let pending = null;
846
+
847
+ const closeConfirm = () => closePanels();
848
+
849
+ for (const btn of document.querySelectorAll('[data-confirm]')) {
850
+ btn.addEventListener('click', () => {
851
+ const what = btn.dataset.confirm;
852
+ // The same button again closes its question rather than re-asking it.
853
+ if (pending === what && solWindow.openId() === 'confirm-form') { closePanels(); return; }
854
+ closePanels(); // including whatever else was open
855
+ pending = what;
856
+ const spec = LIFECYCLE[what];
857
+ $(`warn-${what}`).hidden = false;
858
+ solWindow.show('confirm-form', spec.title);
859
+ $('confirm-go').className = spec.danger ? 'danger' : 'primary';
860
+ $('confirm-go').textContent = spec.go || 'Confirm';
861
+ if (spec.focus) $(spec.focus).focus();
862
+ else if (what === 'retire') $('confirm-handle').focus();
863
+ });
864
+ }
865
+
866
+ // Offered from inside the retire warning: someone reading it has already said
867
+ // what they want ("not this account, here, any more") and these are the two
868
+ // answers that are not destruction. Switching panels rather than closing means
869
+ // they do not have to go and find the button themselves.
870
+ const openConfirm = (what) => {
871
+ closePanels(); // or `show` toggles: same panel, different warning
872
+ document.querySelector(`[data-confirm="${what}"]`).click();
873
+ };
874
+ // Park lives on the status control now; from inside the retire warning it is
875
+ // still one click — close the question and park.
876
+ $('go-park').addEventListener('click', () => { closePanels(); setStatus(true); });
877
+ $('go-move').addEventListener('click', () => openConfirm('move'));
878
+
879
+ $('confirm-cancel').addEventListener('click', () => { closeConfirm(); say('nothing changed'); });
880
+
881
+ $('confirm-form').addEventListener('submit', async (ev) => {
882
+ ev.preventDefault();
883
+ if (!pending) return;
884
+ const what = pending;
885
+ const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
886
+ : what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
887
+ : what === 'move-state' ? {
888
+ to: picked('stateWhere') === 'pod' ? 'pod'
889
+ : picked('stateWhere') === 'url' ? $('state-url').value.trim() : $('state-path').value.trim(),
890
+ }
891
+ : {};
892
+ if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
893
+ if (what === 'move-state' && !body.to) { say('name the destination', 'err'); return; }
894
+ $('confirm-go').disabled = true;
895
+ say(what === 'move-state' ? 'moving your private data — copying, checking, then switching over'
896
+ : `${what} — this talks to the pod and to other servers, so it takes a moment`);
897
+ const r = await write(LIFECYCLE[what].path, body, what);
898
+ $('confirm-go').disabled = false;
899
+ if (!r) return; // write() already said why
900
+ closeConfirm();
901
+ say(LIFECYCLE[what].done(r));
902
+ load(); // mode, and whether it is retired, both changed
903
+ });
904
+
905
+ // ---- inbox ----
906
+ // Only appears when there is enough waiting to be worth a decision. The agent
907
+ // drains oldest-first regardless; this exists to let the owner say "do not
908
+ // bother with that fortnight", which is not a call an agent should make on
909
+ // someone's mail by itself.
910
+ const INBOX_PROMPT_AT = 500;
911
+ let dismissed = false;
912
+
913
+ async function renderInbox() {
914
+ if (dismissed) return;
915
+ const { json: st } = await api('/status');
916
+ const box = st?.inbox;
917
+ const panel = $('pane-inbox');
918
+ if (!box || box.count < INBOX_PROMPT_AT) { panel.hidden = true; return; }
919
+ const mb = box.bytes >= 1048576
920
+ ? `${(box.bytes / 1048576).toFixed(1)} MB` : `${Math.round(box.bytes / 1024)} kB`;
921
+ const since = box.oldest ? new Date(box.oldest).toLocaleDateString() : 'unknown';
922
+ $('inbox-summary').textContent =
923
+ `${box.count.toLocaleString()} deliveries waiting (${mb}), the oldest from ${since}.`;
924
+ // One request each to read and delete, and the agent holds itself to 60 a
925
+ // minute, so the honest number is minutes not seconds.
926
+ $('inbox-warn').hidden = box.count < 2000;
927
+ $('inbox-warn').textContent = box.count >= 2000
928
+ ? `At 60 requests a minute this is roughly ${Math.ceil(box.count * 2 / 60)} minutes of `
929
+ + 'draining if you keep everything. Discarding the old content is much quicker.'
930
+ : '';
931
+ panel.hidden = false;
932
+ }
933
+
934
+ // The inbox-gateway / multi-user-front operator panel is parked: not shown in
935
+ // the UI yet. The backend routes (GET/POST /gateway) still exist and work, so
936
+ // this can be re-added when the feature is ready to expose.
937
+
938
+ $('inbox-keep').addEventListener('click', () => {
939
+ dismissed = true;
940
+ $('pane-inbox').hidden = true;
941
+ say('leaving it to the agent — it drains oldest first');
942
+ });
943
+
944
+ $('inbox-prune').addEventListener('click', async () => {
945
+ const days = Number($('inbox-before').value);
946
+ const before = new Date(Date.now() - days * 86400_000).toISOString();
947
+ $('inbox-prune').disabled = true;
948
+ say(`discarding content older than ${days} days — this takes a while`);
949
+ const r = await write('/inbox/prune', { before }, 'done');
950
+ $('inbox-prune').disabled = false;
951
+ if (r) {
952
+ say(`applied ${r.applied} follow/unfollow/delete, discarded ${r.dropped + r.discarded} posts`
953
+ + (r.failed ? `, ${r.failed} failed` : ''));
954
+ renderInbox();
955
+ }
956
+ });
957
+
958
+ load();