From 14a4790c8b4118427d83bad650b07ef18f04b6fb Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 7 May 2026 16:56:29 +0700 Subject: [PATCH] feat(p2p settings): add browser p2p stats --- package.json | 4 +- public/translations/ar/default.json | 8 +- public/translations/bn/default.json | 8 +- public/translations/cs/default.json | 8 +- public/translations/da/default.json | 8 +- public/translations/de/default.json | 8 +- public/translations/el/default.json | 8 +- public/translations/en/default.json | 8 +- public/translations/es/default.json | 8 +- public/translations/fa/default.json | 8 +- public/translations/fi/default.json | 8 +- public/translations/fil/default.json | 8 +- public/translations/fr/default.json | 8 +- public/translations/he/default.json | 8 +- public/translations/hi/default.json | 8 +- public/translations/hu/default.json | 8 +- public/translations/id/default.json | 8 +- public/translations/it/default.json | 8 +- public/translations/ja/default.json | 8 +- public/translations/ko/default.json | 8 +- public/translations/mr/default.json | 8 +- public/translations/nl/default.json | 8 +- public/translations/no/default.json | 8 +- public/translations/pl/default.json | 8 +- public/translations/pt/default.json | 8 +- public/translations/ro/default.json | 8 +- public/translations/ru/default.json | 8 +- public/translations/sq/default.json | 8 +- public/translations/sv/default.json | 8 +- public/translations/te/default.json | 8 +- public/translations/th/default.json | 8 +- public/translations/tr/default.json | 8 +- public/translations/uk/default.json | 8 +- public/translations/ur/default.json | 8 +- public/translations/vi/default.json | 8 +- public/translations/zh/default.json | 8 +- .../__tests__/settings-modal.test.tsx | 24 + .../__tests__/advanced-settings.test.tsx | 61 +++ .../advanced-settings.module.css | 13 + .../advanced-settings/advanced-settings.tsx | 63 ++- .../__tests__/p2p-stats-settings.test.tsx | 226 +++++++++ .../p2p-stats-settings/index.ts | 1 + .../p2p-stats-settings.module.css | 54 +++ .../p2p-stats-settings/p2p-stats-settings.tsx | 434 ++++++++++++++++++ .../settings-modal/settings-modal.tsx | 27 +- src/lib/__tests__/p2p-browser-config.test.ts | 78 +++- src/lib/__tests__/p2p-runtime.test.ts | 63 +++ src/lib/p2p-browser-config.ts | 67 ++- src/lib/p2p-runtime.ts | 72 +++ src/views/faq/faq.tsx | 13 +- yarn.lock | 226 ++------- 51 files changed, 1457 insertions(+), 249 deletions(-) create mode 100644 src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx create mode 100644 src/components/settings-modal/p2p-stats-settings/index.ts create mode 100644 src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css create mode 100644 src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx create mode 100644 src/lib/__tests__/p2p-runtime.test.ts create mode 100644 src/lib/p2p-runtime.ts diff --git a/package.json b/package.json index c59b60c4..971a3c0c 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,13 @@ "license": "GPL-3.0-or-later", "private": true, "dependencies": { - "@bitsocial/bitsocial-react-hooks": "0.1.6", + "@bitsocial/bitsocial-react-hooks": "0.1.8", "@capacitor/app": "7.0.1", "@capacitor/status-bar": "7.0.1", "@capawesome/capacitor-android-edge-to-edge-support": "7.2.2", "@chenglou/pretext": "0.0.5", "@floating-ui/react": "0.26.1", - "@pkcprotocol/pkc-js": "0.0.23", + "@pkcprotocol/pkc-js": "0.0.29", "@react-spring/web": "10.0.3", "@types/node": "20.19.37", "@types/react": "18.2.25", diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 852b249e..65fce96e 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الشهر الماضي]", "more_threads_last_year": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من السنة الماضية]", "iframe_challenge_confirm": "{{board}} يريد فتح {{site}}.\n\nبالنسبة إلى {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "غير متاح من هذه الشبكة. جرّب شبكة أخرى أو VPN." + "media_hosting_provider_unavailable": "غير متاح من هذه الشبكة. جرّب شبكة أخرى أو VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index 890e1fe2..3bdf42b6 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত মাসের আরও থ্রেড দেখান]", "more_threads_last_year": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত বছরের আরও থ্রেড দেখান]", "iframe_challenge_confirm": "{{board}} {{site}} খুলতে চায়।\n\n{{publicationType}}-এর জন্য: {{excerpt}}", - "media_hosting_provider_unavailable": "এই নেটওয়ার্ক থেকে উপলভ্য নয়। অন্য নেটওয়ার্ক বা একটি VPN চেষ্টা করুন।" + "media_hosting_provider_unavailable": "এই নেটওয়ার্ক থেকে উপলভ্য নয়। অন্য নেটওয়ার্ক বা একটি VPN চেষ্টা করুন।", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 950ff808..f43a13a9 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého měsíce]", "more_threads_last_year": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého roku]", "iframe_challenge_confirm": "{{board}} chce otevřít {{site}}.\n\nPro {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Z této sítě není k dispozici. Zkuste jinou síť nebo VPN." + "media_hosting_provider_unavailable": "Z této sítě není k dispozici. Zkuste jinou síť nebo VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 7810ad7f..46c992c8 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste måned]", "more_threads_last_year": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste år]", "iframe_challenge_confirm": "{{board}} vil åbne {{site}}.\n\nFor {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Ikke tilgængelig fra dette netværk. Prøv et andet netværk eller en VPN." + "media_hosting_provider_unavailable": "Ikke tilgængelig fra dette netværk. Prøv et andet netværk eller en VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 3592395c..7f6ee322 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Monat anzeigen]", "more_threads_last_year": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Jahr anzeigen]", "iframe_challenge_confirm": "{{board}} möchte {{site}} öffnen.\n\nFür {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Von diesem Netzwerk aus nicht verfügbar. Versuche ein anderes Netzwerk oder ein VPN." + "media_hosting_provider_unavailable": "Von diesem Netzwerk aus nicht verfügbar. Versuche ein anderes Netzwerk oder ein VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 0680a066..354ba172 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από τον τελευταίο μήνα]", "more_threads_last_year": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από το περασμένο έτος]", "iframe_challenge_confirm": "Το {{board}} θέλει να ανοίξει το {{site}}.\n\nΓια {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Δεν είναι διαθέσιμο από αυτό το δίκτυο. Δοκιμάστε άλλο δίκτυο ή VPN." + "media_hosting_provider_unavailable": "Δεν είναι διαθέσιμο από αυτό το δίκτυο. Δοκιμάστε άλλο δίκτυο ή VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 038d62e6..8fbd0747 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Month]", "more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year]", "iframe_challenge_confirm": "{{board}} wants to open {{site}}.\n\nFor {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Unavailable from this network. Try another network or a VPN." + "media_hosting_provider_unavailable": "Unavailable from this network. Try another network or a VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index 7492281b..a4c9e396 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del mes pasado]", "more_threads_last_year": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del año pasado]", "iframe_challenge_confirm": "{{board}} quiere abrir {{site}}.\n\nPara {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "No disponible desde esta red. Prueba con otra red o una VPN." + "media_hosting_provider_unavailable": "No disponible desde esta red. Prueba con otra red o una VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 3d6604aa..30fcd557 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های ماه گذشته]", "more_threads_last_year": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های سال گذشته]", "iframe_challenge_confirm": "{{board}} می‌خواهد {{site}} را باز کند.\n\nبرای {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "از این شبکه در دسترس نیست. شبکه دیگری یا یک VPN را امتحان کنید." + "media_hosting_provider_unavailable": "از این شبکه در دسترس نیست. شبکه دیگری یا یک VPN را امتحان کنید.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index ded1f63b..c25843dd 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime kuun ketjuja]", "more_threads_last_year": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime vuoden ketjuja]", "iframe_challenge_confirm": "{{board}} haluaa avata sivuston {{site}}.\n\nKohteelle {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Ei saatavilla tästä verkosta. Kokeile toista verkkoa tai VPN:ää." + "media_hosting_provider_unavailable": "Ei saatavilla tästä verkosta. Kokeile toista verkkoa tai VPN:ää.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 9023d6ff..463ef547 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang buwan]", "more_threads_last_year": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang taon]", "iframe_challenge_confirm": "Nais buksan ng {{board}} ang {{site}}.\n\nPara sa {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Hindi available mula sa network na ito. Subukan ang ibang network o VPN." + "media_hosting_provider_unavailable": "Hindi available mula sa network na ito. Subukan ang ibang network o VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 47167153..b87de9c9 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils du mois dernier]", "more_threads_last_year": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de l'année dernière]", "iframe_challenge_confirm": "{{board}} veut ouvrir {{site}}.\n\nPour {{publicationType}} : {{excerpt}}", - "media_hosting_provider_unavailable": "Indisponible depuis ce réseau. Essayez un autre réseau ou un VPN." + "media_hosting_provider_unavailable": "Indisponible depuis ce réseau. Essayez un autre réseau ou un VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 9763d26b..a9456530 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהחודש שעבר]", "more_threads_last_year": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהשנה שעברה]", "iframe_challenge_confirm": "{{board}} רוצה לפתוח את {{site}}.\n\nעבור {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "לא זמין מהרשת הזו. נסו רשת אחרת או VPN." + "media_hosting_provider_unavailable": "לא זמין מהרשת הזו. נסו רשת אחרת או VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index d155dc63..17c5f00d 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>अगले महीने के अधिक थ्रेड्स दिखाएं]", "more_threads_last_year": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>पिछले साल के अधिक थ्रेड्स दिखाएं]", "iframe_challenge_confirm": "{{board}} {{site}} खोलना चाहता है।\n\n{{publicationType}} के लिए: {{excerpt}}", - "media_hosting_provider_unavailable": "इस नेटवर्क से उपलब्ध नहीं है। कोई दूसरा नेटवर्क या VPN आज़माएँ।" + "media_hosting_provider_unavailable": "इस नेटवर्क से उपलब्ध नहीं है। कोई दूसरा नेटवर्क या VPN आज़माएँ।", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index ebbe90fe..071cd274 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt hónapból]", "more_threads_last_year": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt évből]", "iframe_challenge_confirm": "{{board}} meg szeretné nyitni a következőt: {{site}}.\n\n{{publicationType}} esetén: {{excerpt}}", - "media_hosting_provider_unavailable": "Erről a hálózatról nem érhető el. Próbálj ki egy másik hálózatot vagy VPN-t." + "media_hosting_provider_unavailable": "Erről a hálózatról nem érhető el. Próbálj ki egy másik hálózatot vagy VPN-t.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 0fd34e9b..34e513d4 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari bulan lalu]", "more_threads_last_year": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari tahun lalu]", "iframe_challenge_confirm": "{{board}} ingin membuka {{site}}.\n\nUntuk {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Tidak tersedia dari jaringan ini. Coba jaringan lain atau VPN." + "media_hosting_provider_unavailable": "Tidak tersedia dari jaringan ini. Coba jaringan lain atau VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 3063050a..2781809a 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread del mese scorso]", "more_threads_last_year": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread dell'anno scorso]", "iframe_challenge_confirm": "{{board}} vuole aprire {{site}}.\n\nPer {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Non disponibile da questa rete. Prova un'altra rete o una VPN." + "media_hosting_provider_unavailable": "Non disponibile da questa rete. Prova un'altra rete o una VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index a218f114..567aceb7 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先月のスレッドをさらに表示]", "more_threads_last_year": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>去年のスレッドをさらに表示]", "iframe_challenge_confirm": "{{board}}が{{site}}を開こうとしています。\n\n{{publicationType}}の場合: {{excerpt}}", - "media_hosting_provider_unavailable": "このネットワークからは利用できません。別のネットワークかVPNをお試しください。" + "media_hosting_provider_unavailable": "このネットワークからは利用できません。別のネットワークかVPNをお試しください。", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index d1a9071e..064eafb8 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 달의 스레드 더 보기]", "more_threads_last_year": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>작년의 스레드 더 보기]", "iframe_challenge_confirm": "{{board}}에서 {{site}} 열기를 요청했습니다.\n\n{{publicationType}}의 경우: {{excerpt}}", - "media_hosting_provider_unavailable": "이 네트워크에서는 사용할 수 없습니다. 다른 네트워크나 VPN을 시도해 보세요." + "media_hosting_provider_unavailable": "이 네트워크에서는 사용할 수 없습니다. 다른 네트워크나 VPN을 시도해 보세요.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 5eead025..0aa4bd2e 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या महिन्यातील आणखी थ्रेड्स दाखवा]", "more_threads_last_year": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या वर्षातील आणखी थ्रेड्स दाखवा]", "iframe_challenge_confirm": "{{board}} ला {{site}} उघडायचे आहे.\n\n{{publicationType}} साठी: {{excerpt}}", - "media_hosting_provider_unavailable": "या नेटवर्कवरून उपलब्ध नाही. दुसरे नेटवर्क किंवा VPN वापरून पाहा." + "media_hosting_provider_unavailable": "या नेटवर्कवरून उपलब्ध नाही. दुसरे नेटवर्क किंवा VPN वापरून पाहा.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index e934a8ac..f5bd95ee 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen maand tonen]", "more_threads_last_year": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen jaar tonen]", "iframe_challenge_confirm": "{{board}} wil {{site}} openen.\n\nVoor {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Niet beschikbaar via dit netwerk. Probeer een ander netwerk of een VPN." + "media_hosting_provider_unavailable": "Niet beschikbaar via dit netwerk. Probeer een ander netwerk of een VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 7ff199bf..1cde80bd 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige måned]", "more_threads_last_year": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige år]", "iframe_challenge_confirm": "{{board}} vil åpne {{site}}.\n\nFor {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Ikke tilgjengelig fra dette nettverket. Prøv et annet nettverk eller en VPN." + "media_hosting_provider_unavailable": "Ikke tilgjengelig fra dette nettverket. Prøv et annet nettverk eller en VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 84aee884..83c79d0b 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego miesiąca]", "more_threads_last_year": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego roku]", "iframe_challenge_confirm": "{{board}} chce otworzyć {{site}}.\n\nDla {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Niedostępne z tej sieci. Spróbuj użyć innej sieci lub VPN." + "media_hosting_provider_unavailable": "Niedostępne z tej sieci. Spróbuj użyć innej sieci lub VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index a16f3cee..1e6f7bf3 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do mês passado]", "more_threads_last_year": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do ano passado]", "iframe_challenge_confirm": "{{board}} quer abrir {{site}}.\n\nPara {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Indisponível nesta rede. Tente outra rede ou uma VPN." + "media_hosting_provider_unavailable": "Indisponível nesta rede. Tente outra rede ou uma VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 1ee1e233..80913c49 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de luna trecută]", "more_threads_last_year": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de anul trecut]", "iframe_challenge_confirm": "{{board}} vrea să deschidă {{site}}.\n\nPentru {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Indisponibil din această rețea. Încearcă altă rețea sau un VPN." + "media_hosting_provider_unavailable": "Indisponibil din această rețea. Încearcă altă rețea sau un VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 98fd2cb8..a4619d57 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого месяца]", "more_threads_last_year": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого года]", "iframe_challenge_confirm": "{{board}} хочет открыть {{site}}.\n\nДля {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Недоступно из этой сети. Попробуйте другую сеть или VPN." + "media_hosting_provider_unavailable": "Недоступно из этой сети. Попробуйте другую сеть или VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 504404aa..d52b38d6 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga muaji i kaluar]", "more_threads_last_year": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga viti i kaluar]", "iframe_challenge_confirm": "{{board}} dëshiron të hapë {{site}}.\n\nPër {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Nuk disponohet nga ky rrjet. Provoni një rrjet tjetër ose një VPN." + "media_hosting_provider_unavailable": "Nuk disponohet nga ky rrjet. Provoni një rrjet tjetër ose një VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 6820d2f5..c734b24f 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra månaden]", "more_threads_last_year": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra året]", "iframe_challenge_confirm": "{{board}} vill öppna {{site}}.\n\nFör {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Inte tillgängligt från det här nätverket. Prova ett annat nätverk eller en VPN." + "media_hosting_provider_unavailable": "Inte tillgängligt från det här nätverket. Prova ett annat nätverk eller en VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 74c21691..952101e1 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత నెల నుండి మరిన్ని థ్రెడ్‌లను చూపించండి]", "more_threads_last_year": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత సంవత్సరం నుండి మరిన్ని థ్రెడ్‌లను చూపించండి]", "iframe_challenge_confirm": "{{board}} {{site}} ను తెరవాలని కోరుతోంది.\n\n{{publicationType}} కోసం: {{excerpt}}", - "media_hosting_provider_unavailable": "ఈ నెట్‌వర్క్ నుండి అందుబాటులో లేదు. మరో నెట్‌వర్క్ లేదా VPN ప్రయత్నించండి." + "media_hosting_provider_unavailable": "ఈ నెట్‌వర్క్ నుండి అందుబాటులో లేదు. మరో నెట్‌వర్క్ లేదా VPN ప్రయత్నించండి.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index a5f12020..32887caf 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากเดือนที่แล้วเพิ่มเติม]", "more_threads_last_year": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากปีที่แล้วเพิ่มเติม]", "iframe_challenge_confirm": "{{board}} ต้องการเปิด {{site}}\n\nสำหรับ {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "ไม่สามารถใช้งานได้จากเครือข่ายนี้ ลองใช้เครือข่ายอื่นหรือ VPN" + "media_hosting_provider_unavailable": "ไม่สามารถใช้งานได้จากเครือข่ายนี้ ลองใช้เครือข่ายอื่นหรือ VPN", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 501a3683..dc7b4f42 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen aydan daha fazla konu göster]", "more_threads_last_year": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen yıldan daha fazla konu göster]", "iframe_challenge_confirm": "{{board}}, {{site}} sitesini açmak istiyor.\n\n{{publicationType}} için: {{excerpt}}", - "media_hosting_provider_unavailable": "Bu ağdan kullanılamıyor. Başka bir ağ veya VPN deneyin." + "media_hosting_provider_unavailable": "Bu ağdan kullanılamıyor. Başka bir ağ veya VPN deneyin.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index e03109d5..448d6a25 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого місяця]", "more_threads_last_year": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого року]", "iframe_challenge_confirm": "{{board}} хоче відкрити {{site}}.\n\nДля {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Недоступно з цієї мережі. Спробуйте іншу мережу або VPN." + "media_hosting_provider_unavailable": "Недоступно з цієї мережі. Спробуйте іншу мережу або VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 8330e111..b54186c2 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>پچھلے ماہ کے مزید تھریڈز دکھائیں]", "more_threads_last_year": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>گزشتہ سال کے مزید تھریڈز دکھائیں]", "iframe_challenge_confirm": "{{board}}، {{site}} کھولنا چاہتا ہے۔\n\n{{publicationType}} کے لیے: {{excerpt}}", - "media_hosting_provider_unavailable": "اس نیٹ ورک سے دستیاب نہیں۔ کوئی دوسرا نیٹ ورک یا VPN آزمائیں۔" + "media_hosting_provider_unavailable": "اس نیٹ ورک سے دستیاب نہیں۔ کوئی دوسرا نیٹ ورک یا VPN آزمائیں۔", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 45c791df..b0d623a1 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ tháng trước]", "more_threads_last_year": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ năm ngoái]", "iframe_challenge_confirm": "{{board}} muốn mở {{site}}.\n\nDành cho {{publicationType}}: {{excerpt}}", - "media_hosting_provider_unavailable": "Không khả dụng từ mạng này. Hãy thử mạng khác hoặc VPN." + "media_hosting_provider_unavailable": "Không khả dụng từ mạng này. Hãy thử mạng khác hoặc VPN.", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 677531c7..f2e23a7d 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -365,5 +365,11 @@ "more_threads_last_month": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多上个月的主题]", "more_threads_last_year": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多去年的主题]", "iframe_challenge_confirm": "{{board}} 想要打开 {{site}}。\n\n对于 {{publicationType}}:{{excerpt}}", - "media_hosting_provider_unavailable": "此网络无法使用。请尝试其他网络或 VPN。" + "media_hosting_provider_unavailable": "此网络无法使用。请尝试其他网络或 VPN。", + "p2p_stats": "P2P stats", + "enable_pure_p2p": "Enable pure P2P", + "enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.", + "p2p_stats_loading": "Loading P2P stats...", + "p2p_stats_updated": "Updated", + "p2p_stats_starting": "P2P stats appear after the P2P client starts." } diff --git a/src/components/settings-modal/__tests__/settings-modal.test.tsx b/src/components/settings-modal/__tests__/settings-modal.test.tsx index 2d52f01e..d2f574d8 100644 --- a/src/components/settings-modal/__tests__/settings-modal.test.tsx +++ b/src/components/settings-modal/__tests__/settings-modal.test.tsx @@ -8,6 +8,18 @@ import SettingsModal from '../settings-modal'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; +const testState = vi.hoisted(() => ({ + account: { + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + } as Record, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -42,6 +54,10 @@ vi.mock('../subscriptions-setting', () => ({ default: () =>
subscriptions-settings
, })); +vi.mock('../p2p-stats-settings', () => ({ + default: () =>
p2p-stats-settings
, +})); + const LocationProbe = () => { const location = useLocation(); return
{location.pathname + location.hash}
; @@ -134,6 +150,7 @@ describe('SettingsModal', () => { expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull(); expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull(); expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull(); const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => (candidate.textContent ?? '').includes('collapse_all_settings'), @@ -151,6 +168,13 @@ describe('SettingsModal', () => { expect(container.querySelector('[data-testid="account-settings"]')).toBeNull(); expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull(); expect(container.querySelector('[data-testid="advanced-settings-panel"]')).toBeNull(); + expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).toBeNull(); + }); + + it('opens the p2p stats section from its hash', () => { + render('/all/settings#p2p-stats-settings'); + + expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull(); }); it('closes the modal when the overlay is clicked', async () => { diff --git a/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx b/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx index 5aea3993..2c53678c 100644 --- a/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx +++ b/src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx @@ -84,6 +84,7 @@ const clickButton = async (text: string) => { describe('AdvancedSettings', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); testState.account = { mediaIpfsGatewayUrl: 'https://media.old.example', chainProviders: { @@ -192,6 +193,66 @@ describe('AdvancedSettings', () => { expect(textInputs[2]?.value).toBe('/tmp/connected-node'); }); + it('saves the browser pure p2p toggle through advanced settings', async () => { + await renderSettings(false); + + const checkbox = container.querySelector('input[type="checkbox"]'); + expect(checkbox?.checked).toBe(false); + expect(container.textContent).not.toContain('pure P2P:'); + expect(checkbox?.closest('label')?.nextElementSibling?.textContent).toBe('enable_pure_p2p_tip'); + + await act(async () => { + checkbox?.click(); + }); + await clickButton('save_advanced_settings'); + + expect(testState.setAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ + pkcOptions: expect.objectContaining({ + httpRoutersOptions: ['https://router.old.example'], + ipfsGatewayUrls: undefined, + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + pkcRpcClientsOptions: undefined, + pubsubKuboRpcClientsOptions: undefined, + }), + }), + ); + expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('true'); + expect(reloadMock).toHaveBeenCalledOnce(); + }); + + it('saves gateway mode defaults when browser pure p2p is disabled', async () => { + testState.account = { + mediaIpfsGatewayUrl: 'https://media.old.example', + pkcOptions: { + httpRoutersOptions: ['https://peers.pleb.bot'], + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + }; + + await renderSettings(false); + + const checkbox = container.querySelector('input[type="checkbox"]'); + expect(checkbox?.checked).toBe(true); + + await act(async () => { + checkbox?.click(); + }); + await clickButton('save_advanced_settings'); + + expect(testState.setAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ + pkcOptions: expect.objectContaining({ + httpRoutersOptions: ['https://peers.pleb.bot'], + ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'], + libp2pJsClientsOptions: undefined, + pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'], + }), + }), + ); + expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('false'); + }); + it('toggles the node rpc instructions panel', async () => { await renderSettings(false); diff --git a/src/components/settings-modal/advanced-settings/advanced-settings.module.css b/src/components/settings-modal/advanced-settings/advanced-settings.module.css index 9b1477c4..0075895b 100644 --- a/src/components/settings-modal/advanced-settings/advanced-settings.module.css +++ b/src/components/settings-modal/advanced-settings/advanced-settings.module.css @@ -78,6 +78,19 @@ padding-left: 1px; } +.pureP2PSettings { + margin-bottom: 15px; +} + +.pureP2PSettings .settingTip { + margin: 2px 0 5px 0; + padding-left: 19px; +} + +.pureP2PCheckbox { + margin-right: 5px; +} + .p2pRPCSettings button { margin-left: 5px; } diff --git a/src/components/settings-modal/advanced-settings/advanced-settings.tsx b/src/components/settings-modal/advanced-settings/advanced-settings.tsx index 5329f31a..15ef1495 100644 --- a/src/components/settings-modal/advanced-settings/advanced-settings.tsx +++ b/src/components/settings-modal/advanced-settings/advanced-settings.tsx @@ -1,6 +1,8 @@ import { memo, RefObject, useRef, useState } from 'react'; import { setAccount, useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks'; import { useTranslation } from 'react-i18next'; +import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, setPureP2PBrowserPreference } from '../../../lib/p2p-browser-config'; +import { canConfigureBrowserPureP2P, isBrowserPureP2PEnabled } from '../../../lib/p2p-runtime'; import styles from './advanced-settings.module.css'; interface SettingsProps { @@ -12,6 +14,7 @@ interface SettingsProps { solRpcRef?: RefObject; p2pRpcRef?: RefObject; p2pDataPathRef?: RefObject; + pureP2PBrowserRef?: RefObject; } type AccountProtocolOptions = { @@ -19,6 +22,8 @@ type AccountProtocolOptions = { dataPath?: string; httpRoutersOptions?: string[]; ipfsGatewayUrls?: string[]; + kuboRpcClientsOptions?: unknown[]; + libp2pJsClientsOptions?: unknown[]; pkcRpcClientsOptions?: string[]; pubsubHttpClientsOptions?: string[]; pubsubKuboRpcClientsOptions?: string[]; @@ -210,6 +215,21 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => { ); }; +const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => { + const { t } = useTranslation(); + const account = useAccount() as AccountShape | undefined; + + return ( +
+ +
{t('enable_pure_p2p_tip')}
+
+ ); +}; + const isElectron = window.electronApi?.isElectron === true; const AdvancedSettings = () => { @@ -225,6 +245,7 @@ const AdvancedSettings = () => { const httpRoutersRef = useRef(null); const p2pRpcRef = useRef(null); const p2pDataPathRef = useRef(null); + const pureP2PBrowserRef = useRef(null); const handleSave = async () => { const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value @@ -256,6 +277,7 @@ const AdvancedSettings = () => { const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined; const dataPath = p2pDataPathRef.current?.value.trim() || undefined; + const pureP2PBrowserPreference = canConfigureBrowserPureP2P() ? pureP2PBrowserRef.current?.checked : undefined; const chainProviders: Record = {}; if (ethRpcUrls && ethRpcUrls.length > 0) { @@ -265,20 +287,44 @@ const AdvancedSettings = () => { chainProviders.sol = { urls: solRpcUrls, chainId: 101 }; } + let pkcOptions: AccountProtocolOptions = { + ...protocolOptions, + ipfsGatewayUrls, + pubsubKuboRpcClientsOptions, + httpRoutersOptions, + pkcRpcClientsOptions, + dataPath, + }; + + if (pureP2PBrowserPreference !== undefined) { + if (pureP2PBrowserPreference) { + const pureP2POptions = getBrowserPureP2PPkcOptions(); + pkcOptions = { + ...pkcOptions, + ...pureP2POptions, + httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : pureP2POptions.httpRoutersOptions, + pkcRpcClientsOptions: undefined, + }; + } else { + const gatewayOptions = getBrowserGatewayPkcOptions(); + pkcOptions = { + ...pkcOptions, + ...gatewayOptions, + ipfsGatewayUrls: ipfsGatewayUrls?.length ? ipfsGatewayUrls : gatewayOptions.ipfsGatewayUrls, + pubsubKuboRpcClientsOptions: pubsubKuboRpcClientsOptions?.length ? pubsubKuboRpcClientsOptions : gatewayOptions.pubsubKuboRpcClientsOptions, + httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : gatewayOptions.httpRoutersOptions, + }; + } + } + try { await setAccount({ ...account, mediaIpfsGatewayUrl, chainProviders, - pkcOptions: { - ...protocolOptions, - ipfsGatewayUrls, - pubsubKuboRpcClientsOptions, - httpRoutersOptions, - pkcRpcClientsOptions, - dataPath, - }, + pkcOptions, }); + if (pureP2PBrowserPreference !== undefined) setPureP2PBrowserPreference(pureP2PBrowserPreference); alert('Options saved, reloading...'); window.location.reload(); } catch (e) { @@ -293,6 +339,7 @@ const AdvancedSettings = () => { return (
+ {canConfigureBrowserPureP2P() && }
IPFS gateways: diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx new file mode 100644 index 00000000..146e09c4 --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx @@ -0,0 +1,226 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: {} as Record, + rpcSettings: { state: 'disconnected' } as Record, + setAccountMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + setAccount: (account: unknown) => testState.setAccountMock(account), + useAccount: () => testState.account, + usePkcRpcSettings: () => testState.rpcSettings, +})); + +let container: HTMLDivElement; +let root: Root; + +const loadComponent = async (isElectron = false) => { + vi.resetModules(); + window.electronApi = isElectron ? ({ isElectron: true } as Window['electronApi']) : undefined; + window.isElectron = isElectron; + return (await import('../p2p-stats-settings')).default; +}; + +const renderSettings = async (isElectron = false) => { + const P2PStatsSettings = await loadComponent(isElectron); + await act(async () => { + root.render(createElement(P2PStatsSettings)); + await Promise.resolve(); + }); +}; + +describe('P2PStatsSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + window.electronApi = undefined; + window.isElectron = false; + testState.account = { + id: 'account-1', + author: { address: 'author', wallets: {} }, + pkcOptions: { + ipfsGatewayUrls: ['https://gateway.example'], + }, + }; + testState.rpcSettings = { state: 'disconnected' }; + testState.setAccountMock.mockReset().mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders browser libp2p stats from the active PKC client', async () => { + testState.account = { + ...testState.account, + pkcOptions: { + httpRoutersOptions: ['https://router.example'], + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + heliaWithKuboRpcClientFunctions: { + add: async () => { + throw new Error("Helia 'add' is not supported at the moment in pkc-js API"); + }, + }, + _helia: { + libp2p: { + getConnections: () => ['connection-1'], + getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'], + getPeers: () => ['peer-1', 'peer-2'], + metrics: { + toJSON: () => ({ + helia_bitswap_data_received_bytes: { global: 2048, peer1: 2048 }, + helia_bitswap_sent_data_bytes_total: 1024, + }), + }, + peerId: { toString: () => 'self-peer' }, + services: { + pubsub: { + getPeers: () => ['peer-1'], + }, + }, + }, + routing: { + routers: [ + { + async provide() { + // noop + }, + }, + ], + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => Promise.resolve()); + + expect(container.textContent).toContain('leeching'); + expect(container.textContent).not.toContain('browser Helia'); + expect(container.textContent).not.toContain('seed mode'); + expect(container.textContent).not.toContain('status'); + expect(container.textContent).toContain('self-peer'); + expect(container.textContent).toContain('2 peers'); + expect(container.textContent).toContain('downloaded data'); + expect(container.textContent).toContain('2.00 KB'); + expect(container.textContent).toContain('uploaded data'); + expect(container.textContent).toContain('1.00 KB'); + expect(container.textContent).not.toContain('pubsub topics'); + expect(container.textContent).not.toContain('topic subscribers'); + }); + + it('reads browser transfer counters from Helia bitswap ledgers', async () => { + testState.account = { + ...testState.account, + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + _helia: { + blockstore: { + child: { + blockBrokers: [ + { + bitswap: { + peerWantLists: { + ledgerMap: new Map([['peer-1', { bytesReceived: 4096, bytesSent: 2048 }]]), + }, + }, + }, + ], + }, + }, + libp2p: { + getConnections: () => [], + getMultiaddrs: () => [], + getPeers: () => [], + peerId: { toString: () => 'self-peer' }, + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => Promise.resolve()); + + expect(container.textContent).toContain('downloaded data'); + expect(container.textContent).toContain('4.00 KB'); + expect(container.textContent).toContain('uploaded data'); + expect(container.textContent).toContain('2.00 KB'); + }); + + it('reports seeding only when browser Helia can add and publish provider records', async () => { + testState.account = { + ...testState.account, + pkcOptions: { + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + }, + pkc: { + clients: { + libp2pJsClients: { + libp2pjs: { + key: 'libp2pjs', + heliaWithKuboRpcClientFunctions: { + add: async () => ({ cid: 'cid' }), + }, + _helia: { + libp2p: { + getConnections: () => [], + getMultiaddrs: () => [], + getPeers: () => [], + peerId: { toString: () => 'self-peer' }, + }, + routing: { + routers: [ + { + provide: async (cid: unknown) => cid, + }, + ], + }, + }, + }, + }, + }, + }, + }; + + await renderSettings(false); + await act(async () => Promise.resolve()); + + expect(container.textContent).toContain('seeding'); + expect(container.textContent).not.toContain('seed mode'); + }); +}); diff --git a/src/components/settings-modal/p2p-stats-settings/index.ts b/src/components/settings-modal/p2p-stats-settings/index.ts new file mode 100644 index 00000000..7abb0b64 --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/index.ts @@ -0,0 +1 @@ +export { default } from './p2p-stats-settings'; diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css new file mode 100644 index 00000000..48bfc9c1 --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css @@ -0,0 +1,54 @@ +.content { + margin-left: 10px; +} + +.setting { + margin-bottom: 15px; +} + +.settingTip { + display: block; + font-size: 0.85em; + margin: 4px 0 0 20px; +} + +.stats { + margin: 6px 0 0; + width: calc(100% - 8px); + border-collapse: collapse; +} + +.stats th { + text-align: left; + font-weight: 700; +} + +.stats th, +.stats td { + padding: 2px 4px 3px 0; + vertical-align: top; +} + +.statName { + width: 34%; + text-transform: capitalize; + white-space: nowrap; +} + +.statValue { + word-break: break-word; +} + +.statsMeta { + margin-top: 5px; + font-size: 0.85em; +} + +.statsMeta button { + margin-left: 5px; +} + +.error { + color: var(--error-text-color, #b00020); + word-break: break-word; +} diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx new file mode 100644 index 00000000..f9ab8a9a --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx @@ -0,0 +1,434 @@ +import { memo, useEffect, useReducer } from 'react'; +import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks'; +import { useTranslation } from 'react-i18next'; +import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime'; +import styles from './p2p-stats-settings.module.css'; + +type AccountShape = Record; + +type StatRow = { + name: string; + value: string; +}; + +type StatsState = { + error?: string; + loading: boolean; + rows: StatRow[]; + updatedAt?: number; +}; + +type StatsAction = + | { + type: 'loading'; + } + | { + rows: StatRow[]; + timestamp: number; + type: 'loaded'; + } + | { + error: string; + timestamp: number; + type: 'failed'; + }; + +type Libp2pClientShape = { + _helia?: { + libp2p?: { + getConnections?: () => unknown[] | Promise; + getMultiaddrs?: () => unknown[] | Promise; + getPeers?: () => unknown[] | Promise; + peerId?: { toString: () => string }; + services?: { + pubsub?: { + getPeers?: () => unknown[] | Promise; + }; + }; + metrics?: unknown; + }; + metrics?: unknown; + routing?: { + routers?: unknown[]; + }; + }; + heliaWithKuboRpcClientFunctions?: { + add?: unknown; + }; + key?: string; +}; + +type TransferStats = { + downloadedBytes?: number; + uploadedBytes?: number; +}; + +const KUBO_API_URL = 'http://localhost:50019/api/v0'; +const STATS_REFRESH_MS = 5000; +const MAX_TRANSFER_COUNTER_DEPTH = 10; +const MAX_TRANSFER_COUNTER_OBJECTS = 400; + +const statsReducer = (state: StatsState, action: StatsAction): StatsState => { + if (action.type === 'loading') return { ...state, error: undefined, loading: true }; + if (action.type === 'loaded') return { loading: false, rows: action.rows, updatedAt: action.timestamp }; + return { error: action.error, loading: false, rows: [], updatedAt: action.timestamp }; +}; + +const getErrorMessage = (error: unknown, fallback = 'Error') => (error instanceof Error ? error.message : String(error || fallback)); + +const formatCount = (count: number, singular: string, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`; + +const formatBytes = (value: unknown) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) return String(value ?? 'unknown'); + if (numericValue < 1024) return `${numericValue} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let size = numericValue / 1024; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`; +}; + +const formatRate = (value: unknown) => `${formatBytes(value)}/s`; + +const stringifyList = (items?: unknown[], maxItems = 5) => { + if (!items?.length) return 'none'; + const values = items.map((item) => String(item)); + const visibleValues = values.slice(0, maxItems); + return values.length > maxItems ? `${visibleValues.join(', ')} +${values.length - maxItems} more` : visibleValues.join(', '); +}; + +const getFirstObjectValue = (value?: Record) => (value ? Object.values(value)[0] : undefined); + +const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object'; + +const getFiniteNumber = (value: unknown) => { + if (value === null || value === undefined) return undefined; + const numericValue = typeof value === 'bigint' ? Number(value) : Number(value); + return Number.isFinite(numericValue) ? numericValue : undefined; +}; + +const addTransferStats = (stats: TransferStats, direction: keyof TransferStats, value: unknown) => { + const numericValue = getFiniteNumber(value); + if (numericValue === undefined) return; + stats[direction] = (stats[direction] ?? 0) + numericValue; +}; + +const getEntries = (value: unknown): [string, unknown][] => { + try { + if (value instanceof Map) return Array.from(value.entries()).map(([key, entry]) => [String(key), entry]); + if (Array.isArray(value)) return value.map((entry, index) => [String(index), entry]); + if (isRecord(value)) return Object.entries(value); + return []; + } catch { + return []; + } +}; + +const toArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object' && Symbol.iterator in value) return Array.from(value as Iterable); + return []; +}; + +const getSafeArray = async (getValue?: () => unknown[] | Promise | undefined): Promise => { + try { + return toArray(getValue ? await getValue() : undefined); + } catch { + return []; + } +}; + +const getTransferStatsFromHeliaCounters = (helia: unknown): TransferStats => { + const stats: TransferStats = {}; + const visited = new WeakSet(); + let objectsVisited = 0; + + const visit = (value: unknown, depth: number) => { + try { + if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH || objectsVisited > MAX_TRANSFER_COUNTER_OBJECTS) return; + visited.add(value); + objectsVisited++; + + if ('bytesReceived' in value || 'bytesSent' in value) { + addTransferStats(stats, 'downloadedBytes', value.bytesReceived); + addTransferStats(stats, 'uploadedBytes', value.bytesSent); + } + + for (const [key, entry] of getEntries(value)) { + if (typeof entry === 'function' || key === 'logger' || key === 'log' || key === 'events' || key === 'datastore' || key === 'routing') continue; + visit(entry, depth + 1); + } + } catch { + return; + } + }; + + visit(helia, 0); + return stats; +}; + +const classifyTransferMetricPath = (path: string[]) => { + const normalizedPath = path + .join('_') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + if (normalizedPath.includes('rate')) return undefined; + if ( + normalizedPath.includes('totalin') || + normalizedPath.includes('bytesreceived') || + normalizedPath.includes('receivedbytes') || + normalizedPath.includes('datareceivedbytes') + ) { + return 'downloadedBytes' as const; + } + if ( + normalizedPath.includes('totalout') || + normalizedPath.includes('bytessent') || + normalizedPath.includes('sentbytes') || + normalizedPath.includes('datasentbytes') || + normalizedPath.includes('sentdatabytes') + ) { + return 'uploadedBytes' as const; + } + return undefined; +}; + +const getMetricSnapshot = async (source: unknown) => { + if (!isRecord(source)) return source; + for (const method of ['getMetrics', 'getMetricValues', 'toJSON']) { + const candidate = source[method]; + if (typeof candidate !== 'function') continue; + try { + const snapshot = await candidate.call(source); + if (snapshot !== undefined) return snapshot; + } catch { + return undefined; + } + } + return source; +}; + +const getTransferStatsFromMetricSnapshot = (snapshot: unknown): TransferStats => { + const stats: TransferStats = {}; + const visited = new WeakSet(); + + const visit = (value: unknown, path: string[], depth: number) => { + const direction = classifyTransferMetricPath(path); + const numericValue = getFiniteNumber(value); + if (direction && numericValue !== undefined) { + addTransferStats(stats, direction, numericValue); + return; + } + + if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH) return; + visited.add(value); + + if (direction && 'global' in value) { + addTransferStats(stats, direction, value.global); + return; + } + + if (direction && 'value' in value) { + addTransferStats(stats, direction, value.value); + return; + } + + for (const [key, entry] of getEntries(value)) visit(entry, [...path, key], depth + 1); + }; + + visit(snapshot, [], 0); + return stats; +}; + +const mergeTransferStats = (primary: TransferStats, fallback: TransferStats): TransferStats => ({ + downloadedBytes: primary.downloadedBytes ?? fallback.downloadedBytes, + uploadedBytes: primary.uploadedBytes ?? fallback.uploadedBytes, +}); + +const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise => { + try { + const helia = client?._helia; + const counterStats = getTransferStatsFromHeliaCounters(helia); + const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean); + let metricStats: TransferStats = {}; + + for (const source of metricSources) { + metricStats = mergeTransferStats(metricStats, getTransferStatsFromMetricSnapshot(await getMetricSnapshot(source))); + } + + return mergeTransferStats(counterStats, metricStats); + } catch { + return {}; + } +}; + +const getFunctionSource = (value: unknown) => { + if (typeof value !== 'function') return undefined; + try { + return Function.prototype.toString.call(value).toLowerCase(); + } catch { + return undefined; + } +}; + +const hasSupportedAdd = (client?: Libp2pClientShape) => { + const add = client?.heliaWithKuboRpcClientFunctions?.add; + const source = getFunctionSource(add); + return typeof add === 'function' && !source?.includes('not supported') && !source?.includes('unsupported'); +}; + +const isKnownNoopProvide = (provide: unknown) => { + const source = getFunctionSource(provide); + if (typeof provide !== 'function') return true; + return Boolean(source?.includes('noop') || source?.replace(/\s/g, '') === 'asyncprovide(){}'); +}; + +const hasProviderPublishingRouter = (client?: Libp2pClientShape) => + (client?._helia?.routing?.routers ?? []).some((router) => isRecord(router) && !isKnownNoopProvide(router.provide)); + +const getBrowserMode = (client?: Libp2pClientShape) => { + if (!client) return 'unknown'; + return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'seeding' : 'leeching'; +}; + +const getBrowserLibp2pStats = async (account?: AccountShape): Promise => { + const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined; + const libp2p = client?._helia?.libp2p; + const pubsub = libp2p?.services?.pubsub; + const [peers, connections, multiaddrs, pubsubPeers, transferStats] = await Promise.all([ + getSafeArray(() => libp2p?.getPeers?.()), + getSafeArray(() => libp2p?.getConnections?.()), + getSafeArray(() => libp2p?.getMultiaddrs?.()), + getSafeArray(() => pubsub?.getPeers?.()), + getBrowserTransferStats(client), + ]); + + return [ + { name: 'mode', value: getBrowserMode(client) }, + { name: 'peer id', value: libp2p?.peerId?.toString() ?? 'unknown' }, + { name: 'client key', value: client?.key ?? 'unknown' }, + { name: 'connected peers', value: formatCount(peers.length, 'peer') }, + { name: 'connections', value: formatCount(connections.length, 'connection') }, + { name: 'downloaded data', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) }, + { name: 'uploaded data', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) }, + { name: 'listen addresses', value: stringifyList(multiaddrs) }, + { name: 'pubsub peers', value: formatCount(pubsubPeers.length, 'peer') }, + { name: 'routers', value: stringifyList(account?.pkcOptions?.httpRoutersOptions) }, + ]; +}; + +const kuboPostJson = async (path: string, params?: Record, signal?: AbortSignal) => { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) searchParams.set(key, String(value)); + const query = searchParams.toString(); + const response = await fetch(`${KUBO_API_URL}/${path}${query ? `?${query}` : ''}`, { method: 'POST', signal }); + if (!response.ok) throw new Error(`Kubo ${path} returned ${response.status}`); + const text = await response.text(); + const firstJsonLine = text + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + return firstJsonLine ? JSON.parse(firstJsonLine) : {}; +}; + +const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Promise => { + const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([ + kuboPostJson('id', undefined, signal), + kuboPostJson('version', undefined, signal), + kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal), + kuboPostJson('stats/bw', undefined, signal), + kuboPostJson('repo/stat', undefined, signal), + kuboPostJson('bitswap/stat', undefined, signal), + ]); + + return [ + { name: 'mode', value: 'desktop Kubo' }, + { name: 'PKC RPC', value: rpcState ?? 'unknown' }, + { name: 'peer id', value: identity.ID ?? 'unknown' }, + { name: 'agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' }, + { name: 'connected peers', value: formatCount(Array.isArray(peers.Peers) ? peers.Peers.length : 0, 'peer') }, + { name: 'bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` }, + { name: 'bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` }, + { name: 'repo size', value: formatBytes(repo.RepoSize) }, + { name: 'repo objects', value: String(repo.NumObjects ?? 'unknown') }, + { name: 'bitswap peers', value: formatCount(Array.isArray(bitswap.Peers) ? bitswap.Peers.length : 0, 'peer') }, + { name: 'bitswap wantlist', value: formatCount(Array.isArray(bitswap.Wantlist) ? bitswap.Wantlist.length : 0, 'item') }, + ]; +}; + +const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => { + if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account); + return getElectronKuboStats(rpcState, signal); +}; + +const P2PStatsSettings = () => { + const { t } = useTranslation(); + const account = useAccount() as AccountShape | undefined; + const pkcRpcSettings = usePkcRpcSettings(); + const mode = getP2PRuntimeMode(account); + const [statsState, dispatchStats] = useReducer(statsReducer, { loading: !!mode, rows: [] }); + const updatedAtLabel = statsState.updatedAt ? new Date(statsState.updatedAt).toLocaleTimeString() : undefined; + + useEffect(() => { + const abortController = new AbortController(); + const { signal } = abortController; + const activeMode = mode; + const rpcState = pkcRpcSettings?.state; + + if (!activeMode) return () => abortController.abort(); + + const refreshStats = async () => { + dispatchStats({ type: 'loading' }); + try { + const rows = await getP2PStats(activeMode, account, rpcState, signal); + if (!signal.aborted) dispatchStats({ rows, timestamp: Date.now(), type: 'loaded' }); + } catch (error) { + if (!signal.aborted) { + dispatchStats({ + error: getErrorMessage(error), + timestamp: Date.now(), + type: 'failed', + }); + } + } + }; + + void refreshStats(); + const intervalId = window.setInterval(refreshStats, STATS_REFRESH_MS); + return () => { + abortController.abort(); + window.clearInterval(intervalId); + }; + }, [account, mode, pkcRpcSettings?.state]); + + return ( +
+ {mode ? ( + <> + + + {statsState.rows.map((row) => ( + + + + + ))} + +
{row.name}{row.value}
+
+ {statsState.loading ? t('p2p_stats_loading') : updatedAtLabel ? `${t('p2p_stats_updated')} ${updatedAtLabel}` : null} + {statsState.error &&
{statsState.error}
} +
+ + ) : ( +
{t('p2p_stats_starting')}
+ )} +
+ ); +}; + +export default memo(P2PStatsSettings); diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx index 3f5b2ea5..5b4cd671 100644 --- a/src/components/settings-modal/settings-modal.tsx +++ b/src/components/settings-modal/settings-modal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAccount } from '@bitsocial/bitsocial-react-hooks'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import styles from './settings-modal.module.css'; @@ -9,21 +10,25 @@ import InterfaceSettings from './interface-settings'; import MediaHostingSettings from './media-hosting-settings'; import AdvancedSettings from './advanced-settings'; import SubscriptionsSetting from './subscriptions-setting'; +import P2PStatsSettings from './p2p-stats-settings'; +import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime'; const allSectionIds = ['interface-settings', 'media-hosting-settings', 'account-settings', 'subscriptions-settings', 'advanced-settings']; -const hashToSection = (hash: string): string | null => { +const hashToSection = (hash: string, sectionIds = allSectionIds): string | null => { if (hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings') return 'account-settings'; - if (allSectionIds.includes(hash)) return hash; + if (sectionIds.includes(hash)) return hash; return null; }; const SettingsModal = () => { const { t } = useTranslation(); + const account = useAccount(); const location = useLocation(); const navigate = useNavigate(); const hash = location.hash.slice(1); - const hashSection = hashToSection(hash); + const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]); + const hashSection = hashToSection(hash, sectionIds); const closeModal = useCallback(() => { const newPath = location.pathname.replace(/\/settings$/, ''); @@ -59,8 +64,9 @@ const SettingsModal = () => { const showAccountSettings = visibleExpandedSections.has('account-settings'); const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings'); const showAdvancedSettings = visibleExpandedSections.has('advanced-settings'); + const showP2PStatsSettings = visibleExpandedSections.has(P2P_STATS_SECTION_ID); - const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]); + const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]); const basePath = location.pathname; @@ -89,7 +95,7 @@ const SettingsModal = () => { setExpandedSections(new Set()); navigate(basePath, { replace: true }); } else { - setExpandedSections(new Set(allSectionIds)); + setExpandedSections(new Set(sectionIds)); navigate(basePath, { replace: true }); } }; @@ -169,6 +175,17 @@ const SettingsModal = () => { {showAdvancedSettings && } + {sectionIds.includes(P2P_STATS_SECTION_ID) && ( + <> +
+ +
+ {showP2PStatsSettings && } + + )} ); diff --git a/src/lib/__tests__/p2p-browser-config.test.ts b/src/lib/__tests__/p2p-browser-config.test.ts index 8ae51478..6f267579 100644 --- a/src/lib/__tests__/p2p-browser-config.test.ts +++ b/src/lib/__tests__/p2p-browser-config.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { configureP2PBrowserPkcOptions, isP2PBrowserHostname, P2P_BROWSER_PKC_OPTIONS } from '../p2p-browser-config'; +import { + configureP2PBrowserPkcOptions, + getPureP2PBrowserPreference, + isP2PBrowserHostname, + P2P_BROWSER_PKC_OPTIONS, + PURE_P2P_BROWSER_SETTING_KEY, + setPureP2PBrowserPreference, + shouldUsePureP2PBrowser, +} from '../p2p-browser-config'; + +const createStorage = (values: Record = {}) => ({ + getItem: (key: string) => values[key] ?? null, + setItem: (key: string, value: string) => { + values[key] = value; + }, +}); describe('p2p-browser-config', () => { it('detects p2p subdomains', () => { @@ -10,31 +25,78 @@ describe('p2p-browser-config', () => { expect(isP2PBrowserHostname('www.p2p.5chan.app')).toBe(false); }); - it('configures browser PKC options for p2p hostnames', () => { + it('leaves browser PKC options untouched by default', () => { const targetWindow = { - location: { hostname: 'p2p.5chan.app' }, + location: { hostname: '5chan.app' }, + localStorage: createStorage(), + defaultPkcOptions: { + ipfsGatewayUrls: ['https://gateway.example'], + }, + }; + + expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false); + expect(targetWindow.defaultPkcOptions).toEqual({ + ipfsGatewayUrls: ['https://gateway.example'], + }); + }); + + it('configures browser PKC options when pure p2p is enabled', () => { + const targetWindow = { + location: { hostname: '5chan.app' }, + localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'true' }), defaultPkcOptions: { ipfsGatewayUrls: ['https://gateway.example'], }, }; expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(true); - expect(targetWindow.defaultPkcOptions).toEqual({ - ipfsGatewayUrls: ['https://gateway.example'], - ...P2P_BROWSER_PKC_OPTIONS, - }); + expect(targetWindow.defaultPkcOptions).toEqual(P2P_BROWSER_PKC_OPTIONS); }); - it('leaves normal hostnames untouched', () => { + it('leaves browser PKC options untouched when pure p2p is disabled', () => { const defaultPkcOptions = { ipfsGatewayUrls: ['https://gateway.example'], }; const targetWindow = { location: { hostname: '5chan.app' }, + localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'false' }), defaultPkcOptions, }; expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false); expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions); }); + + it('leaves electron defaults untouched', () => { + const defaultPkcOptions = { + pkcRpcClientsOptions: ['ws://localhost:9138'], + }; + const targetWindow = { + electronApi: { isElectron: true }, + location: { hostname: 'localhost' }, + localStorage: createStorage(), + defaultPkcOptions, + }; + + expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false); + expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions); + }); + + it('persists and reads the browser pure p2p preference', () => { + const targetWindow = { + location: { hostname: '5chan.app' }, + localStorage: createStorage(), + }; + + expect(getPureP2PBrowserPreference(targetWindow)).toBeUndefined(); + expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false); + + setPureP2PBrowserPreference(false, targetWindow); + expect(getPureP2PBrowserPreference(targetWindow)).toBe(false); + expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false); + + setPureP2PBrowserPreference(true, targetWindow); + expect(getPureP2PBrowserPreference(targetWindow)).toBe(true); + expect(shouldUsePureP2PBrowser(targetWindow)).toBe(true); + }); }); diff --git a/src/lib/__tests__/p2p-runtime.test.ts b/src/lib/__tests__/p2p-runtime.test.ts new file mode 100644 index 00000000..11ee18e7 --- /dev/null +++ b/src/lib/__tests__/p2p-runtime.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + getBrowserGatewayAccountOptions, + getBrowserPureP2PAccountOptions, + getP2PRuntimeMode, + isBrowserPureP2PEnabled, + shouldShowP2PSettingsSection, +} from '../p2p-runtime'; + +const browserWindow = { + electronApi: undefined, + isElectron: false, + localStorage: { + getItem: () => null, + setItem: () => undefined, + }, +} as unknown as Window; + +const electronWindow = { + electronApi: { isElectron: true }, + isElectron: true, +} as unknown as Window; + +describe('p2p-runtime', () => { + it('detects browser libp2p accounts from options and live clients', () => { + expect(getP2PRuntimeMode({ pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }] } }, browserWindow)).toBe('browser-libp2p'); + expect(getP2PRuntimeMode({ pkc: { clients: { libp2pJsClients: { libp2pjs: {} } } } }, browserWindow)).toBe('browser-libp2p'); + }); + + it('detects electron Kubo RPC accounts only in electron runtime', () => { + const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } }; + + expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc'); + expect(getP2PRuntimeMode(account, browserWindow)).toBeNull(); + }); + + it('shows p2p settings in browsers only when pure p2p is enabled or active', () => { + expect(shouldShowP2PSettingsSection(undefined, browserWindow)).toBe(false); + expect(shouldShowP2PSettingsSection({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false); + expect(isBrowserPureP2PEnabled({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false); + }); + + it('builds browser p2p and gateway account options without a direct pkc-js import', () => { + const account = { + pkcOptions: { + httpRoutersOptions: ['https://custom-router.example'], + ipfsGatewayUrls: ['https://gateway.example'], + pkcRpcClientsOptions: ['ws://remote.example'], + }, + }; + + expect(getBrowserPureP2PAccountOptions(account)).toMatchObject({ + libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + ipfsGatewayUrls: undefined, + pkcRpcClientsOptions: undefined, + }); + expect(getBrowserGatewayAccountOptions(account)).toMatchObject({ + ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'], + libp2pJsClientsOptions: undefined, + pkcRpcClientsOptions: undefined, + }); + }); +}); diff --git a/src/lib/p2p-browser-config.ts b/src/lib/p2p-browser-config.ts index a84afd71..69747698 100644 --- a/src/lib/p2p-browser-config.ts +++ b/src/lib/p2p-browser-config.ts @@ -1,24 +1,85 @@ +export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled'; + export const P2P_BROWSER_PKC_OPTIONS = { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], + ipfsGatewayUrls: undefined, + kuboRpcClientsOptions: undefined, + pubsubHttpClientsOptions: undefined, + pubsubKuboRpcClientsOptions: undefined, httpRoutersOptions: ['https://peers.pleb.bot', 'https://peers.forumindex.com'], }; +const GATEWAY_BROWSER_PKC_OPTIONS = { + ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'], + kuboRpcClientsOptions: undefined, + libp2pJsClientsOptions: undefined, + pubsubHttpClientsOptions: undefined, + pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'], + httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'], +}; + type P2PBrowserConfigWindow = { location: Pick; defaultPkcOptions?: Record; + electronApi?: { isElectron?: boolean }; + isElectron?: boolean; + localStorage?: Pick; }; export const isP2PBrowserHostname = (hostname: string) => hostname.toLowerCase().startsWith('p2p.'); +export const getBrowserPureP2PPkcOptions = () => ({ + ...P2P_BROWSER_PKC_OPTIONS, + libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })), + httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions], +}); + +export const getBrowserGatewayPkcOptions = () => ({ + ...GATEWAY_BROWSER_PKC_OPTIONS, + ipfsGatewayUrls: [...GATEWAY_BROWSER_PKC_OPTIONS.ipfsGatewayUrls], + pubsubKuboRpcClientsOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.pubsubKuboRpcClientsOptions], + httpRoutersOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.httpRoutersOptions], +}); + +export const getPureP2PBrowserPreference = (targetWindow: P2PBrowserConfigWindow = window) => { + try { + const storedValue = targetWindow.localStorage?.getItem(PURE_P2P_BROWSER_SETTING_KEY); + if (storedValue === 'true') return true; + if (storedValue === 'false') return false; + } catch { + return undefined; + } + + return undefined; +}; + +export const setPureP2PBrowserPreference = (enabled: boolean, targetWindow: P2PBrowserConfigWindow = window) => { + try { + targetWindow.localStorage?.setItem(PURE_P2P_BROWSER_SETTING_KEY, String(enabled)); + } catch { + return; + } +}; + +export const isElectronRuntime = (targetWindow: P2PBrowserConfigWindow = window) => targetWindow.electronApi?.isElectron === true || targetWindow.isElectron === true; + +export const shouldUsePureP2PBrowser = (targetWindow: P2PBrowserConfigWindow = window) => { + if (isElectronRuntime(targetWindow)) return false; + + const preference = getPureP2PBrowserPreference(targetWindow); + if (preference !== undefined) return preference; + + return false; +}; + export const configureP2PBrowserPkcOptions = (targetWindow: P2PBrowserConfigWindow = window) => { - if (!isP2PBrowserHostname(targetWindow.location.hostname)) { + if (!shouldUsePureP2PBrowser(targetWindow)) { return false; } targetWindow.defaultPkcOptions = { ...targetWindow.defaultPkcOptions, - libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })), - httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions], + ...getBrowserPureP2PPkcOptions(), }; return true; diff --git a/src/lib/p2p-runtime.ts b/src/lib/p2p-runtime.ts new file mode 100644 index 00000000..ba8a9459 --- /dev/null +++ b/src/lib/p2p-runtime.ts @@ -0,0 +1,72 @@ +import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, isElectronRuntime, shouldUsePureP2PBrowser } from './p2p-browser-config'; + +export const P2P_STATS_SECTION_ID = 'p2p-stats-settings'; + +export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc'; + +type AccountProtocolOptions = { + httpRoutersOptions?: string[]; + ipfsGatewayUrls?: string[]; + kuboRpcClientsOptions?: unknown[]; + libp2pJsClientsOptions?: unknown[]; + pkcRpcClientsOptions?: string[]; + pubsubHttpClientsOptions?: unknown[]; + pubsubKuboRpcClientsOptions?: unknown[]; +}; + +type AccountShape = { + pkc?: { + clients?: { + libp2pJsClients?: Record; + pkcRpcClients?: Record; + }; + }; + pkcOptions?: AccountProtocolOptions; +}; + +const toAccountShape = (account: unknown) => account as AccountShape | undefined; + +const hasArrayItems = (value: unknown) => Array.isArray(value) && value.length > 0; + +const hasObjectItems = (value: unknown) => !!value && typeof value === 'object' && Object.keys(value).length > 0; + +export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = window): P2PRuntimeMode | null => { + const accountShape = toAccountShape(account); + const protocolOptions = accountShape?.pkcOptions; + const clients = accountShape?.pkc?.clients; + + if (hasArrayItems(protocolOptions?.libp2pJsClientsOptions) || hasObjectItems(clients?.libp2pJsClients)) { + return 'browser-libp2p'; + } + + if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) { + return 'electron-kubo-rpc'; + } + + return null; +}; + +export const canConfigureBrowserPureP2P = (targetWindow: Window = window) => !isElectronRuntime(targetWindow); + +export const shouldShowP2PSettingsSection = (account?: unknown, targetWindow: Window = window) => + getP2PRuntimeMode(account, targetWindow) !== null || (canConfigureBrowserPureP2P(targetWindow) && isBrowserPureP2PEnabled(account, targetWindow)); + +export const isBrowserPureP2PEnabled = (account?: unknown, targetWindow: Window = window) => { + const accountShape = toAccountShape(account); + if (!canConfigureBrowserPureP2P(targetWindow)) return false; + if (getP2PRuntimeMode(account, targetWindow) === 'browser-libp2p') return true; + if (hasArrayItems(accountShape?.pkcOptions?.ipfsGatewayUrls) || hasArrayItems(accountShape?.pkcOptions?.pubsubKuboRpcClientsOptions)) return false; + return shouldUsePureP2PBrowser(targetWindow); +}; + +export const getBrowserPureP2PAccountOptions = (account?: unknown) => ({ + ...toAccountShape(account)?.pkcOptions, + ...getBrowserPureP2PPkcOptions(), + pkcRpcClientsOptions: undefined, +}); + +export const getBrowserGatewayAccountOptions = (account?: unknown) => ({ + ...toAccountShape(account)?.pkcOptions, + ...getBrowserGatewayPkcOptions(), + pkcRpcClientsOptions: undefined, +}); diff --git a/src/views/faq/faq.tsx b/src/views/faq/faq.tsx index 5ba7fd46..57046496 100644 --- a/src/views/faq/faq.tsx +++ b/src/views/faq/faq.tsx @@ -592,18 +592,13 @@ const FAQ_SECTIONS: FAQSection[] = [ question: 'Can browsers connect peer-to-peer?', answer: ( <> - Yes, experimentally. The standard web app can use HTTP gateways, routers, and pubsub providers as compatibility helpers. The{' '} - - p2p.5chan.app - {' '} - subdomain instead runs a{' '} + Yes. The web app can run a{' '} Helia {' '} - IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. Browser nodes still have restrictions, such as limited - inbound connectivity, so they can load data peer-to-peer but are not a stable way to host board data yet. Helia pubsub is currently unstable for 5chan's - needs, which is why the pure browser P2P site is experimental. The desktop apps are the stable pure-P2P option today because they run a full Bitsocial node - with IPFS Kubo. + IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. You can turn pure browser P2P on in advanced settings and + inspect the connection in P2P stats. Browser nodes still have restrictions, such as limited inbound + connectivity, so desktop apps remain the best way to host board data because they run a full Bitsocial node with IPFS Kubo. ), }, diff --git a/yarn.lock b/yarn.lock index f2de38b9..1589952c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,7 +9,7 @@ __metadata: version: 0.0.0-use.local resolution: "5chan@workspace:." dependencies: - "@bitsocial/bitsocial-react-hooks": "npm:0.1.6" + "@bitsocial/bitsocial-react-hooks": "npm:0.1.8" "@capacitor/android": "npm:7.4.5" "@capacitor/app": "npm:7.0.1" "@capacitor/cli": "npm:7.4.5" @@ -23,7 +23,7 @@ __metadata: "@electron-forge/maker-zip": "npm:7.8.0" "@electron/rebuild": "npm:3.7.2" "@floating-ui/react": "npm:0.26.1" - "@pkcprotocol/pkc-js": "npm:0.0.23" + "@pkcprotocol/pkc-js": "npm:0.0.29" "@react-spring/web": "npm:10.0.3" "@reforged/maker-appimage": "npm:5.1.1" "@types/lodash": "npm:4.17.24" @@ -1522,12 +1522,12 @@ __metadata: languageName: node linkType: hard -"@bitsocial/bitsocial-react-hooks@npm:0.1.6": - version: 0.1.6 - resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.6" +"@bitsocial/bitsocial-react-hooks@npm:0.1.8": + version: 0.1.8 + resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.8" dependencies: - "@bitsocial/bso-resolver": "npm:0.0.6" - "@pkcprotocol/pkc-js": "npm:0.0.24" + "@bitsocial/bso-resolver": "npm:0.0.8" + "@pkcprotocol/pkc-js": "npm:0.0.29" "@pkcprotocol/pkc-logger": "npm:0.1.0" assert: "npm:2.0.0" ethers: "npm:5.8.0" @@ -1543,18 +1543,17 @@ __metadata: zustand: "npm:4.0.0" peerDependencies: react: ">=16.8" - checksum: 10c0/7c93c9430303ed60f32d2eff8601b700ea103f7d801e831967a8f7d34a4876f1087eeaf9bb0250ff36008a7276ad7146934713c3c5f52411faa46219e31576bd + checksum: 10c0/0222c68180ff30372b1ff415bc4c47c47bf1de2ef74bd08457730922498d0eff6ce42b379f96ebefd32a18f37cddff5e2431a7d338634efa7fb6e404e1fc9c2a languageName: node linkType: hard -"@bitsocial/bso-resolver@npm:0.0.6": - version: 0.0.6 - resolution: "@bitsocial/bso-resolver@npm:0.0.6" +"@bitsocial/bso-resolver@npm:0.0.8": + version: 0.0.8 + resolution: "@bitsocial/bso-resolver@npm:0.0.8" dependencies: "@pkcprotocol/pkc-logger": "npm:0.1.0" - better-sqlite3: "npm:12.6.2" viem: "npm:2.48.1" - checksum: 10c0/113e54c08dd86c1e356318e6be1b2b1682753209bba229256352a47bbaef81d340a46c5124f4abf8f49d1289caae479637e902b45ac33e950fc05a34caf0e044 + checksum: 10c0/0acb033cac4f8860323e154a5a53b95e9d107326e78bc9febd36091cd96dc2c095b31996d594fba9d676c132305c5504d5b7e1b3e5af1f78e2c372f1eebc2f7a languageName: node linkType: hard @@ -3984,28 +3983,6 @@ __metadata: languageName: node linkType: hard -"@libp2p/gossipsub@npm:15.0.20": - version: 15.0.20 - resolution: "@libp2p/gossipsub@npm:15.0.20" - dependencies: - "@libp2p/crypto": "npm:^5.1.17" - "@libp2p/interface": "npm:^3.2.2" - "@libp2p/interface-internal": "npm:^3.1.3" - "@libp2p/peer-id": "npm:^6.0.8" - "@libp2p/utils": "npm:^7.1.0" - "@multiformats/multiaddr": "npm:^13.0.1" - denque: "npm:^2.1.0" - it-length-prefixed: "npm:^10.0.1" - it-pipe: "npm:^3.0.1" - it-pushable: "npm:^3.2.3" - multiformats: "npm:^13.0.1" - protons-runtime: "npm:^6.0.1" - uint8arraylist: "npm:^2.4.8" - uint8arrays: "npm:^5.0.1" - checksum: 10c0/0b96b09e3f57768bc7a281a7c043e27fbd1e8cafd03c274a6fc5e33c0391c11f0a1a393e5f4accd7b2c11b822bb430cbcf8590d71e50afa2fce2dae8866ea41f - languageName: node - linkType: hard - "@libp2p/gossipsub@npm:15.0.21": version: 15.0.21 resolution: "@libp2p/gossipsub@npm:15.0.21" @@ -4621,7 +4598,7 @@ __metadata: languageName: node linkType: hard -"@libp2p/websockets@npm:10.1.11, @libp2p/websockets@npm:^10.1.8": +"@libp2p/websockets@npm:^10.1.8": version: 10.1.11 resolution: "@libp2p/websockets@npm:10.1.11" dependencies: @@ -4640,6 +4617,25 @@ __metadata: languageName: node linkType: hard +"@libp2p/webtransport@npm:6.0.22": + version: 6.0.22 + resolution: "@libp2p/webtransport@npm:6.0.22" + dependencies: + "@chainsafe/libp2p-noise": "npm:^17.0.0" + "@libp2p/interface": "npm:^3.2.2" + "@libp2p/peer-id": "npm:^6.0.8" + "@libp2p/utils": "npm:^7.1.0" + "@multiformats/multiaddr": "npm:^13.0.1" + "@multiformats/multiaddr-matcher": "npm:^3.0.1" + multiformats: "npm:^13.3.6" + progress-events: "npm:^1.0.1" + race-signal: "npm:^2.0.0" + uint8arraylist: "npm:^2.4.8" + uint8arrays: "npm:^5.1.0" + checksum: 10c0/152aaf9b2895681b05b243b5fa156eb4c510d655b9880ac105c8963741dae2690e7eef079a1484569e22745a065ea9580ed543121db039ed77327fb715544b32 + languageName: node + linkType: hard + "@malept/cross-spawn-promise@npm:^2.0.0": version: 2.0.0 resolution: "@malept/cross-spawn-promise@npm:2.0.0" @@ -5759,69 +5755,9 @@ __metadata: languageName: node linkType: hard -"@pkcprotocol/pkc-js@npm:0.0.23": - version: 0.0.23 - resolution: "@pkcprotocol/pkc-js@npm:0.0.23" - dependencies: - "@enhances/with-resolvers": "npm:0.0.5" - "@helia/block-brokers": "npm:5.2.4" - "@helia/delegated-routing-v1-http-api-client": "npm:6.0.1" - "@helia/ipns": "npm:9.2.1" - "@helia/unixfs": "npm:7.2.1" - "@libp2p/crypto": "npm:5.1.17" - "@libp2p/fetch": "npm:4.1.3" - "@libp2p/gossipsub": "npm:15.0.20" - "@libp2p/identify": "npm:4.1.3" - "@libp2p/interfaces": "npm:3.3.2" - "@libp2p/peer-id": "npm:6.0.8" - "@libp2p/websockets": "npm:10.1.11" - "@noble/curves": "npm:2.2.0" - "@pkcprotocol/pkc-logger": "npm:0.1.0" - "@pkcprotocol/proper-lock-file": "npm:4.2.1" - assert: "npm:2.1.0" - better-sqlite3: "npm:12.9.0" - blockstore-core: "npm:6.1.2" - blockstore-fs: "npm:3.0.2" - blockstore-idb: "npm:3.0.1" - buffer: "npm:6.0.3" - cbor: "npm:10.0.11" - debounce: "npm:1.2.1" - err-code: "npm:3.0.1" - ext-name: "npm:5.0.0" - helia: "npm:6.1.4" - hpagent: "npm:1.2.0" - it-all: "npm:3.0.6" - js-sha256: "npm:0.11.1" - js-sha512: "npm:0.9.0" - kubo-rpc-client: "npm:6.1.0" - limiter-es6-compat: "npm:2.1.2" - localforage: "npm:1.10.0" - lodash.merge: "npm:4.6.2" - lru-cache: "npm:10.1.0" - open-graph-scraper: "npm:6.11.0" - p-limit: "npm:7.3.0" - p-retry: "npm:8.0.0" - p-timeout: "npm:7.0.1" - probe-image-size: "npm:7.2.3" - remeda: "npm:1.57.0" - retry: "npm:0.13.1" - rpc-websockets: "npm:9.3.8" - safe-stable-stringify: "npm:2.4.3" - sha1-uint8array: "npm:0.10.7" - tcp-port-used: "npm:1.0.2" - tiny-typed-emitter: "npm:2.1.0" - tinycache: "npm:1.1.2" - ts-custom-error: "npm:3.3.1" - typestub-ipfs-only-hash: "npm:4.0.0" - uuid: "npm:13.0.0" - zod: "npm:4.3.6" - checksum: 10c0/c575983ca7d7fadff4f9058d700e0df210159ee086c4b44b31e68f1653f9c429766c12b440b1ab5d3b82dfe1a2807be1b6d7732379d64d5c6694a0f85f0b28f5 - languageName: node - linkType: hard - -"@pkcprotocol/pkc-js@npm:0.0.24": - version: 0.0.24 - resolution: "@pkcprotocol/pkc-js@npm:0.0.24" +"@pkcprotocol/pkc-js@npm:0.0.29": + version: 0.0.29 + resolution: "@pkcprotocol/pkc-js@npm:0.0.29" dependencies: "@enhances/with-resolvers": "npm:0.0.5" "@helia/block-brokers": "npm:5.2.4" @@ -5834,19 +5770,16 @@ __metadata: "@libp2p/identify": "npm:4.1.3" "@libp2p/interfaces": "npm:3.3.2" "@libp2p/peer-id": "npm:6.0.8" - "@libp2p/websockets": "npm:10.1.11" + "@libp2p/webtransport": "npm:6.0.22" "@noble/curves": "npm:2.2.0" "@pkcprotocol/pkc-logger": "npm:0.1.0" "@pkcprotocol/proper-lock-file": "npm:4.2.1" assert: "npm:2.1.0" better-sqlite3: "npm:12.9.0" blockstore-core: "npm:6.1.2" - blockstore-fs: "npm:3.0.2" - blockstore-idb: "npm:3.0.1" buffer: "npm:6.0.3" cbor: "npm:10.0.11" debounce: "npm:1.2.1" - err-code: "npm:3.0.1" ext-name: "npm:5.0.0" helia: "npm:6.1.4" hpagent: "npm:1.2.0" @@ -5867,7 +5800,6 @@ __metadata: retry: "npm:0.13.1" rpc-websockets: "npm:9.3.8" safe-stable-stringify: "npm:2.4.3" - sha1-uint8array: "npm:0.10.7" tcp-port-used: "npm:1.0.2" tiny-typed-emitter: "npm:2.1.0" tinycache: "npm:1.1.2" @@ -5875,7 +5807,7 @@ __metadata: typestub-ipfs-only-hash: "npm:4.0.0" uuid: "npm:13.0.0" zod: "npm:4.3.6" - checksum: 10c0/8acff68a6ce5f1d545ce0a2f47e4a67eb9cc166d37fb9f13c8b3582b2124f984317b68a3ed4e8b7d386c19789f59a1acce8ac1284b40b18715b12a4ba488d7f8 + checksum: 10c0/089100e75d4b2c735f3a9b9f6b087970afb9dc606e21be6e5e1e84daf2e3350793f5cfa8f6508a3db7bc518a1173e517f449174185b2a07f5df7d33f73cffdac languageName: node linkType: hard @@ -7504,17 +7436,6 @@ __metadata: languageName: node linkType: hard -"better-sqlite3@npm:12.6.2": - version: 12.6.2 - resolution: "better-sqlite3@npm:12.6.2" - dependencies: - bindings: "npm:^1.5.0" - node-gyp: "npm:latest" - prebuild-install: "npm:^7.1.1" - checksum: 10c0/a58fb3f7a7f5469ba0b8de0855aa67396ff34f951a6975746e4b21987f530be6a34427d1d4bd5958cf48c67ed7ba1df038ae163d2ee9d944237f6b8112f6640d - languageName: node - linkType: hard - "better-sqlite3@npm:12.9.0": version: 12.9.0 resolution: "better-sqlite3@npm:12.9.0" @@ -7619,7 +7540,7 @@ __metadata: languageName: node linkType: hard -"blockstore-core@npm:6.1.2, blockstore-core@npm:^6.0.0, blockstore-core@npm:^6.1.2": +"blockstore-core@npm:6.1.2, blockstore-core@npm:^6.1.2": version: 6.1.2 resolution: "blockstore-core@npm:6.1.2" dependencies: @@ -7649,38 +7570,6 @@ __metadata: languageName: node linkType: hard -"blockstore-fs@npm:3.0.2": - version: 3.0.2 - resolution: "blockstore-fs@npm:3.0.2" - dependencies: - interface-blockstore: "npm:^6.0.0" - interface-store: "npm:^7.0.0" - it-glob: "npm:^3.0.3" - it-map: "npm:^3.1.3" - it-parallel-batch: "npm:^3.0.8" - multiformats: "npm:^13.3.6" - race-signal: "npm:^2.0.0" - steno: "npm:^4.0.2" - checksum: 10c0/326707456edd7296be495f195a25ab29f92554d07f2cddb03d8648f6f2ea3d2506f06350ae7a4b68938bc930d1a389acea28cdfe9a31db9379d1e23983d598d8 - languageName: node - linkType: hard - -"blockstore-idb@npm:3.0.1": - version: 3.0.1 - resolution: "blockstore-idb@npm:3.0.1" - dependencies: - blockstore-core: "npm:^6.0.0" - idb: "npm:^8.0.3" - interface-blockstore: "npm:^6.0.0" - interface-store: "npm:^7.0.0" - it-all: "npm:^3.0.9" - it-to-buffer: "npm:^4.0.10" - multiformats: "npm:^13.3.6" - race-signal: "npm:^2.0.0" - checksum: 10c0/d2b63f13a120e7aa167cbd64fe2d9e30ec27e2f57bf37455c9fdbe9b25218a4c65a5be7c2139e848ca21ece2cace7116aeaf67ceef0b34971fd6e0c98aa52696 - languageName: node - linkType: hard - "bluebird@npm:^3.1.1": version: 3.7.2 resolution: "bluebird@npm:3.7.2" @@ -9747,13 +9636,6 @@ __metadata: languageName: node linkType: hard -"err-code@npm:3.0.1, err-code@npm:^3.0.0, err-code@npm:^3.0.1": - version: 3.0.1 - resolution: "err-code@npm:3.0.1" - checksum: 10c0/78b1c50500adebde6699b8d27b8ce4728c132dcaad75b5d18ba44f6ccb28769d1fff8368ae1164be4559dac8b95d4e26bb15b480ba9999e0cd0f0c64beaf1b24 - languageName: node - linkType: hard - "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -9761,6 +9643,13 @@ __metadata: languageName: node linkType: hard +"err-code@npm:^3.0.0, err-code@npm:^3.0.1": + version: 3.0.1 + resolution: "err-code@npm:3.0.1" + checksum: 10c0/78b1c50500adebde6699b8d27b8ce4728c132dcaad75b5d18ba44f6ccb28769d1fff8368ae1164be4559dac8b95d4e26bb15b480ba9999e0cd0f0c64beaf1b24 + languageName: node + linkType: hard + "error-ex@npm:^1.2.0, error-ex@npm:^1.3.1, error-ex@npm:^1.3.2": version: 1.3.4 resolution: "error-ex@npm:1.3.4" @@ -11801,13 +11690,6 @@ __metadata: languageName: node linkType: hard -"idb@npm:^8.0.3": - version: 8.0.3 - resolution: "idb@npm:8.0.3" - checksum: 10c0/421cd9a3281b7564528857031cc33fd9e95753f8191e483054cb25d1ceea7303a0d1462f4f69f5b41606f0f066156999e067478abf2460dfcf9cab80dae2a2b2 - languageName: node - linkType: hard - "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -12941,7 +12823,7 @@ __metadata: languageName: node linkType: hard -"it-glob@npm:^3.0.1, it-glob@npm:^3.0.3": +"it-glob@npm:^3.0.1": version: 3.0.4 resolution: "it-glob@npm:3.0.4" dependencies: @@ -13060,7 +12942,7 @@ __metadata: languageName: node linkType: hard -"it-parallel-batch@npm:^3.0.8, it-parallel-batch@npm:^3.0.9": +"it-parallel-batch@npm:^3.0.9": version: 3.0.9 resolution: "it-parallel-batch@npm:3.0.9" dependencies: @@ -17798,13 +17680,6 @@ __metadata: languageName: node linkType: hard -"sha1-uint8array@npm:0.10.7": - version: 0.10.7 - resolution: "sha1-uint8array@npm:0.10.7" - checksum: 10c0/7c2a62aef9c6b8fded4df84d8356038895c8e2a87eedbfb88ea5d74b9e12502ac7c457a3f0708424c5f8f9ce32f040bb89399b352bc772c99f9c0e4e89dddf57 - languageName: node - linkType: hard - "sharp@npm:0.34.5": version: 0.34.5 resolution: "sharp@npm:0.34.5" @@ -18296,13 +18171,6 @@ __metadata: languageName: node linkType: hard -"steno@npm:^4.0.2": - version: 4.0.2 - resolution: "steno@npm:4.0.2" - checksum: 10c0/fda6bb192bb73ce9453586080aae3fc75506e1c10c9285df49849a016bb9378c6146610f27976708437a6c5492619ec09efb9def6d5397624ae0ad70e57ccaf1 - languageName: node - linkType: hard - "stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0"