Merge branch 'codex/feature/p2p-stats-settings'

# Conflicts:
#	package.json
#	public/translations/ar/default.json
#	public/translations/bn/default.json
#	public/translations/cs/default.json
#	public/translations/da/default.json
#	public/translations/de/default.json
#	public/translations/el/default.json
#	public/translations/en/default.json
#	public/translations/es/default.json
#	public/translations/fa/default.json
#	public/translations/fi/default.json
#	public/translations/fil/default.json
#	public/translations/fr/default.json
#	public/translations/he/default.json
#	public/translations/hi/default.json
#	public/translations/hu/default.json
#	public/translations/id/default.json
#	public/translations/it/default.json
#	public/translations/ja/default.json
#	public/translations/ko/default.json
#	public/translations/mr/default.json
#	public/translations/nl/default.json
#	public/translations/no/default.json
#	public/translations/pl/default.json
#	public/translations/pt/default.json
#	public/translations/ro/default.json
#	public/translations/ru/default.json
#	public/translations/sq/default.json
#	public/translations/sv/default.json
#	public/translations/te/default.json
#	public/translations/th/default.json
#	public/translations/tr/default.json
#	public/translations/uk/default.json
#	public/translations/ur/default.json
#	public/translations/vi/default.json
#	public/translations/zh/default.json
#	src/components/settings-modal/__tests__/settings-modal.test.tsx
#	src/components/settings-modal/settings-modal.tsx
#	yarn.lock
This commit is contained in:
Tommaso Casaburi
2026-05-07 16:59:44 +07:00
51 changed files with 1452 additions and 227 deletions
+2 -2
View File
@@ -8,14 +8,14 @@
"license": "GPL-3.0-or-later",
"private": true,
"dependencies": {
"@bitsocial/bitsocial-react-hooks": "0.1.7",
"@bitsocial/bitsocial-react-hooks": "0.1.8",
"@capacitor/app": "7.0.1",
"@capacitor/browser": "7.0.5",
"@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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "لا توجد روابط لوحات موثوقة.",
"trusted_board_link_until_revoked": "حتى يتم الإلغاء",
"trusted_board_link_checkbox": "اسمح دائمًا للوحات بفتح هذا الموقع الإلكتروني",
"revoke": "إلغاء"
"revoke": "إلغاء",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "কোনো বিশ্বস্ত বোর্ড লিংক নেই।",
"trusted_board_link_until_revoked": "প্রত্যাহার না করা পর্যন্ত",
"trusted_board_link_checkbox": "বোর্ডগুলোকে সবসময় এই ওয়েবসাইট খুলতে দিন",
"revoke": "প্রত্যাহার করুন"
"revoke": "প্রত্যাহার করুন",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Žádné důvěryhodné odkazy desek.",
"trusted_board_link_until_revoked": "dokud nebude odvoláno",
"trusted_board_link_checkbox": "Vždy povolit deskám otevírat tento web",
"revoke": "Odvolat"
"revoke": "Odvolat",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Ingen betroede tavlelinks.",
"trusted_board_link_until_revoked": "indtil tilbagekaldt",
"trusted_board_link_checkbox": "Tillad altid tavler at åbne dette websted",
"revoke": "Tilbagekald"
"revoke": "Tilbagekald",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Keine vertrauenswürdigen Board-Links.",
"trusted_board_link_until_revoked": "bis zum Widerruf",
"trusted_board_link_checkbox": "Boards immer erlauben, diese Website zu öffnen",
"revoke": "Widerrufen"
"revoke": "Widerrufen",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Δεν υπάρχουν αξιόπιστοι σύνδεσμοι πινάκων.",
"trusted_board_link_until_revoked": "μέχρι να ανακληθεί",
"trusted_board_link_checkbox": "Να επιτρέπεται πάντα στους πίνακες να ανοίγουν αυτόν τον ιστότοπο",
"revoke": "Ανάκληση"
"revoke": "Ανάκληση",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "No trusted board links.",
"trusted_board_link_until_revoked": "until revoked",
"trusted_board_link_checkbox": "Always allow boards to open this website",
"revoke": "Revoke"
"revoke": "Revoke",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "No hay enlaces de tableros de confianza.",
"trusted_board_link_until_revoked": "hasta que se revoque",
"trusted_board_link_checkbox": "Permitir siempre que los tableros abran este sitio web",
"revoke": "Revocar"
"revoke": "Revocar",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "هیچ پیوند برد مورد اعتمادی وجود ندارد.",
"trusted_board_link_until_revoked": "تا زمان لغو",
"trusted_board_link_checkbox": "همیشه به بردها اجازه دهید این وب‌سایت را باز کنند",
"revoke": "لغو"
"revoke": "لغو",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Ei luotettuja taululinkkejä.",
"trusted_board_link_until_revoked": "kunnes perutaan",
"trusted_board_link_checkbox": "Salli taulujen aina avata tämä verkkosivusto",
"revoke": "Peruuta"
"revoke": "Peruuta",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Walang pinagkakatiwalaang link ng board.",
"trusted_board_link_until_revoked": "hanggang bawiin",
"trusted_board_link_checkbox": "Palaging payagan ang mga board na buksan ang website na ito",
"revoke": "Bawiin"
"revoke": "Bawiin",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Aucun lien de board approuvé.",
"trusted_board_link_until_revoked": "jusqu'à révocation",
"trusted_board_link_checkbox": "Toujours autoriser les boards à ouvrir ce site web",
"revoke": "Révoquer"
"revoke": "Révoquer",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "אין קישורי לוחות מהימנים.",
"trusted_board_link_until_revoked": "עד לביטול",
"trusted_board_link_checkbox": "לאפשר תמיד ללוחות לפתוח את האתר הזה",
"revoke": "בטל"
"revoke": "בטל",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "कोई विश्वसनीय बोर्ड लिंक नहीं।",
"trusted_board_link_until_revoked": "रद्द होने तक",
"trusted_board_link_checkbox": "बोर्ड को हमेशा यह वेबसाइट खोलने दें",
"revoke": "रद्द करें"
"revoke": "रद्द करें",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Nincsenek megbízható táblahivatkozások.",
"trusted_board_link_until_revoked": "visszavonásig",
"trusted_board_link_checkbox": "Mindig engedélyezze a tábláknak ennek a webhelynek a megnyitását",
"revoke": "Visszavonás"
"revoke": "Visszavonás",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Tidak ada tautan papan tepercaya.",
"trusted_board_link_until_revoked": "hingga dicabut",
"trusted_board_link_checkbox": "Selalu izinkan papan membuka situs web ini",
"revoke": "Cabut"
"revoke": "Cabut",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Nessun link di bacheca attendibile.",
"trusted_board_link_until_revoked": "fino alla revoca",
"trusted_board_link_checkbox": "Consenti sempre alle bacheche di aprire questo sito web",
"revoke": "Revoca"
"revoke": "Revoca",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "信頼済みの板リンクはありません。",
"trusted_board_link_until_revoked": "取り消すまで",
"trusted_board_link_checkbox": "板が常にこのウェブサイトを開くことを許可する",
"revoke": "取り消す"
"revoke": "取り消す",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "신뢰한 게시판 링크가 없습니다.",
"trusted_board_link_until_revoked": "취소할 때까지",
"trusted_board_link_checkbox": "게시판이 항상 이 웹사이트를 열도록 허용",
"revoke": "취소"
"revoke": "취소",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "विश्वसनीय बोर्ड दुवे नाहीत.",
"trusted_board_link_until_revoked": "रद्द करेपर्यंत",
"trusted_board_link_checkbox": "बोर्डना नेहमी ही वेबसाइट उघडण्याची परवानगी द्या",
"revoke": "रद्द करा"
"revoke": "रद्द करा",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Geen vertrouwde boardlinks.",
"trusted_board_link_until_revoked": "tot intrekking",
"trusted_board_link_checkbox": "Sta boards altijd toe deze website te openen",
"revoke": "Intrekken"
"revoke": "Intrekken",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Ingen klarerte tavlelenker.",
"trusted_board_link_until_revoked": "til den tilbakekalles",
"trusted_board_link_checkbox": "Tillat alltid tavler å åpne dette nettstedet",
"revoke": "Tilbakekall"
"revoke": "Tilbakekall",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Brak zaufanych linków tablic.",
"trusted_board_link_until_revoked": "do odwołania",
"trusted_board_link_checkbox": "Zawsze pozwalaj tablicom otwierać tę stronę internetową",
"revoke": "Odwołaj"
"revoke": "Odwołaj",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Nenhum link de board confiável.",
"trusted_board_link_until_revoked": "até ser revogado",
"trusted_board_link_checkbox": "Sempre permitir que boards abram este site",
"revoke": "Revogar"
"revoke": "Revogar",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Nu există linkuri de board de încredere.",
"trusted_board_link_until_revoked": "până la revocare",
"trusted_board_link_checkbox": "Permite mereu boardurilor să deschidă acest site web",
"revoke": "Revocă"
"revoke": "Revocă",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Нет доверенных ссылок досок.",
"trusted_board_link_until_revoked": "до отзыва",
"trusted_board_link_checkbox": "Всегда разрешать доскам открывать этот сайт",
"revoke": "Отозвать"
"revoke": "Отозвать",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Nuk ka lidhje bordesh të besuara.",
"trusted_board_link_until_revoked": "derisa të revokohet",
"trusted_board_link_checkbox": "Lejo gjithmonë bordet të hapin këtë faqe interneti",
"revoke": "Revoko"
"revoke": "Revoko",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Inga betrodda boardlänkar.",
"trusted_board_link_until_revoked": "tills den återkallas",
"trusted_board_link_checkbox": "Tillåt alltid boards att öppna den här webbplatsen",
"revoke": "Återkalla"
"revoke": "Återkalla",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "నమ్మదగిన బోర్డ్ లింకులు లేవు.",
"trusted_board_link_until_revoked": "రద్దు చేసే వరకు",
"trusted_board_link_checkbox": "బోర్డులు ఎల్లప్పుడూ ఈ వెబ్‌సైట్‌ను తెరవడానికి అనుమతించు",
"revoke": "రద్దు చేయి"
"revoke": "రద్దు చేయి",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "ไม่มีลิงก์บอร์ดที่เชื่อถือได้",
"trusted_board_link_until_revoked": "จนกว่าจะเพิกถอน",
"trusted_board_link_checkbox": "อนุญาตให้บอร์ดเปิดเว็บไซต์นี้เสมอ",
"revoke": "เพิกถอน"
"revoke": "เพิกถอน",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Güvenilir pano bağlantısı yok.",
"trusted_board_link_until_revoked": "iptal edilene kadar",
"trusted_board_link_checkbox": "Panoların bu web sitesini her zaman açmasına izin ver",
"revoke": "İptal et"
"revoke": "İptal et",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Немає довірених посилань дощок.",
"trusted_board_link_until_revoked": "до відкликання",
"trusted_board_link_checkbox": "Завжди дозволяти дошкам відкривати цей сайт",
"revoke": "Відкликати"
"revoke": "Відкликати",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "کوئی قابل اعتماد بورڈ لنکس نہیں۔",
"trusted_board_link_until_revoked": "منسوخ ہونے تک",
"trusted_board_link_checkbox": "بورڈز کو ہمیشہ یہ ویب سائٹ کھولنے کی اجازت دیں",
"revoke": "منسوخ کریں"
"revoke": "منسوخ کریں",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "Không có liên kết bảng đáng tin cậy.",
"trusted_board_link_until_revoked": "cho đến khi thu hồi",
"trusted_board_link_checkbox": "Luôn cho phép các bảng mở trang web này",
"revoke": "Thu hồi"
"revoke": "Thu hồi",
"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
@@ -371,5 +371,11 @@
"trusted_board_links_empty": "没有受信任的版块链接。",
"trusted_board_link_until_revoked": "直到撤销",
"trusted_board_link_checkbox": "始终允许版块打开此网站",
"revoke": "撤销"
"revoke": "撤销",
"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,
@@ -46,6 +58,10 @@ vi.mock('../trusted-board-links-setting', () => ({
default: () => <div data-testid='trusted-board-links-settings-panel'>trusted-board-links-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>;
@@ -139,6 +155,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="trusted-board-links-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'),
@@ -157,6 +174,13 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="trusted-board-links-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: {
@@ -191,6 +192,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 {
@@ -11,6 +13,7 @@ interface SettingsProps {
ethRpcRef?: RefObject<HTMLTextAreaElement>;
p2pRpcRef?: RefObject<HTMLInputElement>;
p2pDataPathRef?: RefObject<HTMLInputElement>;
pureP2PBrowserRef?: RefObject<HTMLInputElement>;
}
type AccountProtocolOptions = {
@@ -18,6 +21,8 @@ type AccountProtocolOptions = {
dataPath?: string;
httpRoutersOptions?: string[];
ipfsGatewayUrls?: string[];
kuboRpcClientsOptions?: unknown[];
libp2pJsClientsOptions?: unknown[];
pkcRpcClientsOptions?: string[];
pubsubHttpClientsOptions?: string[];
pubsubKuboRpcClientsOptions?: string[];
@@ -197,6 +202,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 = () => {
@@ -211,6 +231,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
@@ -237,26 +258,51 @@ 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) {
chainProviders.eth = { urls: ethRpcUrls, chainId: 1 };
}
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) {
@@ -271,6 +317,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';
@@ -10,6 +11,8 @@ import MediaHostingSettings from './media-hosting-settings';
import AdvancedSettings from './advanced-settings';
import SubscriptionsSetting from './subscriptions-setting';
import TrustedBoardLinksSetting from './trusted-board-links-setting';
import P2PStatsSettings from './p2p-stats-settings';
import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime';
const allSectionIds = [
'interface-settings',
@@ -20,18 +23,20 @@ const allSectionIds = [
'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$/, '');
@@ -68,8 +73,9 @@ const SettingsModal = () => {
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
const showBoardLinkPermissionsSettings = visibleExpandedSections.has('board-link-permissions-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;
@@ -98,7 +104,7 @@ const SettingsModal = () => {
setExpandedSections(new Set());
navigate(basePath, { replace: true });
} else {
setExpandedSections(new Set(allSectionIds));
setExpandedSections(new Set(sectionIds));
navigate(basePath, { replace: true });
}
};
@@ -185,6 +191,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.
</>
),
},
+42 -157
View File
@@ -9,7 +9,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "5chan@workspace:."
dependencies:
"@bitsocial/bitsocial-react-hooks": "npm:0.1.7"
"@bitsocial/bitsocial-react-hooks": "npm:0.1.8"
"@capacitor/android": "npm:7.4.5"
"@capacitor/app": "npm:7.0.1"
"@capacitor/browser": "npm:7.0.5"
@@ -24,7 +24,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"
@@ -1523,12 +1523,12 @@ __metadata:
languageName: node
linkType: hard
"@bitsocial/bitsocial-react-hooks@npm:0.1.7":
version: 0.1.7
resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.7"
"@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.8"
"@pkcprotocol/pkc-js": "npm:0.0.27"
"@pkcprotocol/pkc-js": "npm:0.0.29"
"@pkcprotocol/pkc-logger": "npm:0.1.0"
assert: "npm:2.0.0"
ethers: "npm:5.8.0"
@@ -1544,7 +1544,7 @@ __metadata:
zustand: "npm:4.0.0"
peerDependencies:
react: ">=16.8"
checksum: 10c0/39bd3f263fac36108127ab11f9161092c50860373bd77fa8d933de75f79fd758215d16ddcad207c319ea01cc0564fd5526857a5517c212e46325977e687e07c1
checksum: 10c0/0222c68180ff30372b1ff415bc4c47c47bf1de2ef74bd08457730922498d0eff6ce42b379f96ebefd32a18f37cddff5e2431a7d338634efa7fb6e404e1fc9c2a
languageName: node
linkType: hard
@@ -3993,28 +3993,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"
@@ -4630,7 +4608,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:
@@ -4649,6 +4627,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"
@@ -5768,69 +5765,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.27":
version: 0.0.27
resolution: "@pkcprotocol/pkc-js@npm:0.0.27"
"@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"
@@ -5843,6 +5780,7 @@ __metadata:
"@libp2p/identify": "npm:4.1.3"
"@libp2p/interfaces": "npm:3.3.2"
"@libp2p/peer-id": "npm:6.0.8"
"@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"
@@ -5879,7 +5817,7 @@ __metadata:
typestub-ipfs-only-hash: "npm:4.0.0"
uuid: "npm:13.0.0"
zod: "npm:4.3.6"
checksum: 10c0/19360d831a9ea3ecb586ca02d1763611224bdcc72f2586dc50ffe1a550a56e72c79f173ebce8422ab15623f277889dd96666016ab042d208fb4d185879b82784
checksum: 10c0/089100e75d4b2c735f3a9b9f6b087970afb9dc606e21be6e5e1e84daf2e3350793f5cfa8f6508a3db7bc518a1173e517f449174185b2a07f5df7d33f73cffdac
languageName: node
linkType: hard
@@ -7612,7 +7550,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:
@@ -7642,38 +7580,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"
@@ -9740,13 +9646,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"
@@ -9754,6 +9653,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"
@@ -11794,13 +11700,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"
@@ -12934,7 +12833,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:
@@ -13053,7 +12952,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:
@@ -17791,13 +17690,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"
@@ -18289,13 +18181,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"