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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- 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();
|