feat(p2p settings): add browser p2p stats

This commit is contained in:
Tommaso Casaburi
2026-05-07 16:56:29 +07:00
parent 2ebd9ecc30
commit 14a4790c8b
51 changed files with 1457 additions and 249 deletions
+2 -2
View File
@@ -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",
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الشهر الماضي</1>]",
"more_threads_last_year": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من السنة الماضية</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত মাসের আরও থ্রেড দেখান</1>]",
"more_threads_last_year": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত বছরের আরও থ্রেড দেখান</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."
}
+7 -1
View File
@@ -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</1>]",
"more_threads_last_year": "{{count}} příspěvků od {{currentTimeFilterName}} — [<1>Zobrazit více příspěvků z minulého roku</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste måned</1>]",
"more_threads_last_year": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste år</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Monat anzeigen</1>]",
"more_threads_last_year": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads vom letzten Jahr anzeigen</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από τον τελευταίο μήνα</1>]",
"more_threads_last_year": "{{count}} νήματα από {{currentTimeFilterName}} — [<1>Προβολή περισσότερων νημάτων από το περασμένο έτος</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Month</1>]",
"more_threads_last_year": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Year</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del mes pasado</1>]",
"more_threads_last_year": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Ver más hilos del año pasado</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های ماه گذشته</1>]",
"more_threads_last_year": "{{count}} پست از {{currentTimeFilterName}} — [<1>نمایش بیشتر پست‌های سال گذشته</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime kuun ketjuja</1>]",
"more_threads_last_year": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää viime vuoden ketjuja</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang buwan</1>]",
"more_threads_last_year": "{{count}} mga thread mula {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula noong nakaraang taon</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils du mois dernier</1>]",
"more_threads_last_year": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de l'année dernière</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהחודש שעבר</1>]",
"more_threads_last_year": "{{count}} חוטים מאז {{currentTimeFilterName}} — [<1>הצג עוד חוטים מהשנה שעברה</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>अगले महीने के अधिक थ्रेड्स दिखाएं</1>]",
"more_threads_last_year": "{{count}} थ्रेड्स {{currentTimeFilterName}} से — [<1>पिछले साल के अधिक थ्रेड्स दिखाएं</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."
}
+7 -1
View File
@@ -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</1>]",
"more_threads_last_year": "{{count}} szál {{currentTimeFilterName}} óta — [<1>Több szál megjelenítése a múlt évből</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari bulan lalu</1>]",
"more_threads_last_year": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Tampilkan lebih banyak thread dari tahun lalu</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread del mese scorso</1>]",
"more_threads_last_year": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra altri thread dell'anno scorso</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先月のスレッドをさらに表示</1>]",
"more_threads_last_year": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>去年のスレッドをさらに表示</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 달의 스레드 더 보기</1>]",
"more_threads_last_year": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>작년의 스레드 더 보기</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या महिन्यातील आणखी थ्रेड्स दाखवा</1>]",
"more_threads_last_year": "{{count}} ध糀ं {{currentTimeFilterName}} पासून — [<1>गेल्या वर्षातील आणखी थ्रेड्स दाखवा</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen maand tonen</1>]",
"more_threads_last_year": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Meer threads van afgelopen jaar tonen</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige måned</1>]",
"more_threads_last_year": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige år</1>]",
"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."
}
+7 -1
View File
@@ -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</1>]",
"more_threads_last_year": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego roku</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do mês passado</1>]",
"more_threads_last_year": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos do ano passado</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de luna trecută</1>]",
"more_threads_last_year": "{{count}} subiecte din {{currentTimeFilterName}} — [<1>Afișați mai multe subiecte de anul trecut</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого месяца</1>]",
"more_threads_last_year": "{{count}} темы с {{currentTimeFilterName}} — [<1>Показать больше тем с прошлого года</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga muaji i kaluar</1>]",
"more_threads_last_year": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga viti i kaluar</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra månaden</1>]",
"more_threads_last_year": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra året</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత నెల నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</1>]",
"more_threads_last_year": "{{count}} థ్రెడ్‌లు {{currentTimeFilterName}} నుండి — [<1>గత సంవత్సరం నుండి మరిన్ని థ్రెడ్‌లను చూపించండి</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากเดือนที่แล้วเพิ่มเติม</1>]",
"more_threads_last_year": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้จากปีที่แล้วเพิ่มเติม</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen aydan daha fazla konu göster</1>]",
"more_threads_last_year": "{{count}} konu {{currentTimeFilterName}} itibariyle — [<1>Geçen yıldan daha fazla konu göster</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого місяця</1>]",
"more_threads_last_year": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого року</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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>پچھلے ماہ کے مزید تھریڈز دکھائیں</1>]",
"more_threads_last_year": "{{count}} تھریڈز {{currentTimeFilterName}} سے — [<1>گزشتہ سال کے مزید تھریڈز دکھائیں</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."
}
+7 -1
View File
@@ -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</1>]",
"more_threads_last_year": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Hiển thị thêm các chủ đề từ năm ngoái</1>]",
"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."
}
+7 -1
View File
@@ -365,5 +365,11 @@
"more_threads_last_month": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多上个月的主题</1>]",
"more_threads_last_year": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多去年的主题</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."
}
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
} as Record<string, any>,
}));
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: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
}));
vi.mock('../p2p-stats-settings', () => ({
default: () => <div data-testid='p2p-stats-settings-panel'>p2p-stats-settings</div>,
}));
const LocationProbe = () => {
const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>;
@@ -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 () => {
@@ -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<HTMLInputElement>('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<HTMLInputElement>('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);
@@ -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;
}
@@ -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<HTMLTextAreaElement>;
p2pRpcRef?: RefObject<HTMLInputElement>;
p2pDataPathRef?: RefObject<HTMLInputElement>;
pureP2PBrowserRef?: RefObject<HTMLInputElement>;
}
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 (
<div className={styles.pureP2PSettings}>
<label>
<input className={styles.pureP2PCheckbox} type='checkbox' defaultChecked={isBrowserPureP2PEnabled(account)} ref={pureP2PBrowserRef} />
{t('enable_pure_p2p')}
</label>
<div className={styles.settingTip}>{t('enable_pure_p2p_tip')}</div>
</div>
);
};
const isElectron = window.electronApi?.isElectron === true;
const AdvancedSettings = () => {
@@ -225,6 +245,7 @@ const AdvancedSettings = () => {
const httpRoutersRef = useRef<HTMLTextAreaElement>(null);
const p2pRpcRef = useRef<HTMLInputElement>(null);
const p2pDataPathRef = useRef<HTMLInputElement>(null);
const pureP2PBrowserRef = useRef<HTMLInputElement>(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<string, { urls: string[] | undefined; chainId: number }> = {};
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 (
<div className={styles.content}>
{canConfigureBrowserPureP2P() && <PureP2PBrowserSettings pureP2PBrowserRef={pureP2PBrowserRef} />}
<div className={styles.category}>
<span className={styles.categoryTitle}>IPFS gateways:</span>
<span className={styles.categorySettings}>
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {} as Record<string, any>,
rpcSettings: { state: 'disconnected' } as Record<string, any>,
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');
});
});
@@ -0,0 +1 @@
export { default } from './p2p-stats-settings';
@@ -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;
}
@@ -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<string, any>;
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<unknown[]>;
getMultiaddrs?: () => unknown[] | Promise<unknown[]>;
getPeers?: () => unknown[] | Promise<unknown[]>;
peerId?: { toString: () => string };
services?: {
pubsub?: {
getPeers?: () => unknown[] | Promise<unknown[]>;
};
};
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 = <T,>(value?: Record<string, T>) => (value ? Object.values(value)[0] : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> => !!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<unknown>);
return [];
};
const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | undefined): Promise<unknown[]> => {
try {
return toArray(getValue ? await getValue() : undefined);
} catch {
return [];
}
};
const getTransferStatsFromHeliaCounters = (helia: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
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<object>();
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<TransferStats> => {
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<StatRow[]> => {
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<string, string | boolean>, 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<StatRow[]> => {
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 (
<div className={styles.content} data-testid='p2p-stats-settings-panel'>
{mode ? (
<>
<table className={styles.stats}>
<tbody>
{statsState.rows.map((row) => (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>{row.value}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.statsMeta}>
{statsState.loading ? t('p2p_stats_loading') : updatedAtLabel ? `${t('p2p_stats_updated')} ${updatedAtLabel}` : null}
{statsState.error && <div className={styles.error}>{statsState.error}</div>}
</div>
</>
) : (
<div className={styles.statsMeta}>{t('p2p_stats_starting')}</div>
)}
</div>
);
};
export default memo(P2PStatsSettings);
@@ -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 = () => {
</label>
</div>
{showAdvancedSettings && <AdvancedSettings />}
{sectionIds.includes(P2P_STATS_SECTION_ID) && (
<>
<div id={P2P_STATS_SECTION_ID} className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick(P2P_STATS_SECTION_ID)}>
<span className={showP2PStatsSettings ? styles.hideButton : styles.showButton} />
{t('p2p_stats')}
</label>
</div>
{showP2PStatsSettings && <P2PStatsSettings />}
</>
)}
</div>
</>
);
+70 -8
View File
@@ -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<string, string | undefined> = {}) => ({
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);
});
});
+63
View File
@@ -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,
});
});
});
+64 -3
View File
@@ -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<Location, 'hostname'>;
defaultPkcOptions?: Record<string, unknown>;
electronApi?: { isElectron?: boolean };
isElectron?: boolean;
localStorage?: Pick<Storage, 'getItem' | 'setItem'>;
};
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;
+72
View File
@@ -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<string, unknown>;
pkcRpcClients?: Record<string, unknown>;
};
};
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,
});
+4 -9
View File
@@ -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{' '}
<a href='https://p2p.5chan.app' {...externalLinkProps}>
p2p.5chan.app
</a>{' '}
subdomain instead runs a{' '}
Yes. The web app can run a{' '}
<a href='https://helia.io' {...externalLinkProps}>
Helia
</a>{' '}
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 <Link to='/all/settings#p2p-stats-settings'>P2P stats</Link>. 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.
</>
),
},
+47 -179
View File
@@ -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"