mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/feature/trusted-board-url-permissions'
This commit is contained in:
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} مواضيع منذ {{currentTimeFilterName}} — [<1>عرض المزيد من المواضيع من الأسبوع الماضي</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} يريد فتح {{site}}.",
|
||||
"media_hosting_provider_unavailable": "غير متاح من هذه الشبكة. جرّب شبكة أخرى أو VPN.",
|
||||
"board_link_permissions": "أذونات روابط اللوحات",
|
||||
"trusted_board_links_intro": "يمكن للوحات فتح هذه المواقع بدون سؤال.",
|
||||
"trusted_board_links_empty": "لا توجد روابط لوحات موثوقة.",
|
||||
"trusted_board_link_until_revoked": "حتى يتم الإلغاء",
|
||||
"trusted_board_link_checkbox": "اسمح دائمًا للوحات بفتح هذا الموقع الإلكتروني",
|
||||
"revoke": "إلغاء"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} থ্রেড {{currentTimeFilterName}} থেকে — [<1>গত সপ্তাহের আরও থ্রেড দেখান</1>]",
|
||||
"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 চেষ্টা করুন।"
|
||||
"iframe_challenge_confirm": "{{board}} {{site}} খুলতে চায়।",
|
||||
"media_hosting_provider_unavailable": "এই নেটওয়ার্ক থেকে উপলভ্য নয়। অন্য নেটওয়ার্ক বা একটি VPN চেষ্টা করুন।",
|
||||
"board_link_permissions": "বোর্ড লিংক অনুমতি",
|
||||
"trusted_board_links_intro": "বোর্ডগুলো জিজ্ঞাসা না করেই এই সাইটগুলো খুলতে পারে।",
|
||||
"trusted_board_links_empty": "কোনো বিশ্বস্ত বোর্ড লিংক নেই।",
|
||||
"trusted_board_link_until_revoked": "প্রত্যাহার না করা পর্যন্ত",
|
||||
"trusted_board_link_checkbox": "বোর্ডগুলোকে সবসময় এই ওয়েবসাইট খুলতে দিন",
|
||||
"revoke": "প্রত্যাহার করুন"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} vláken od {{currentTimeFilterName}} — [<1>Ukázat více vláken z minulého týdne</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} chce otevřít {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Z této sítě není k dispozici. Zkuste jinou síť nebo VPN.",
|
||||
"board_link_permissions": "Oprávnění odkazů desek",
|
||||
"trusted_board_links_intro": "Desky mohou tyto weby otevírat bez dotazu.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} tråde siden {{currentTimeFilterName}} — [<1>Vis flere tråde fra sidste uge</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} vil åbne {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Ikke tilgængelig fra dette netværk. Prøv et andet netværk eller en VPN.",
|
||||
"board_link_permissions": "Tilladelser til tavlelinks",
|
||||
"trusted_board_links_intro": "Tavler kan åbne disse websteder uden at spørge.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} Threads seit {{currentTimeFilterName}} — [<1>Mehr Threads aus der letzten Woche anzeigen</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} möchte {{site}} öffnen.",
|
||||
"media_hosting_provider_unavailable": "Von diesem Netzwerk aus nicht verfügbar. Versuche ein anderes Netzwerk oder ein VPN.",
|
||||
"board_link_permissions": "Berechtigungen für Board-Links",
|
||||
"trusted_board_links_intro": "Boards können diese Websites ohne Nachfrage öffnen.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} θέματα από {{currentTimeFilterName}} — [<1>Εμφάνιση περισσότερων θεμάτων από την περασμένη εβδομάδα</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "Το {{board}} θέλει να ανοίξει το {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Δεν είναι διαθέσιμο από αυτό το δίκτυο. Δοκιμάστε άλλο δίκτυο ή VPN.",
|
||||
"board_link_permissions": "Δικαιώματα συνδέσμων πινάκων",
|
||||
"trusted_board_links_intro": "Οι πίνακες μπορούν να ανοίγουν αυτούς τους ιστότοπους χωρίς ερώτηση.",
|
||||
"trusted_board_links_empty": "Δεν υπάρχουν αξιόπιστοι σύνδεσμοι πινάκων.",
|
||||
"trusted_board_link_until_revoked": "μέχρι να ανακληθεί",
|
||||
"trusted_board_link_checkbox": "Να επιτρέπεται πάντα στους πίνακες να ανοίγουν αυτόν τον ιστότοπο",
|
||||
"revoke": "Ανάκληση"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} threads since {{currentTimeFilterName}} — [<1>Show More Threads from Last Week</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} wants to open {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Unavailable from this network. Try another network or a VPN.",
|
||||
"board_link_permissions": "Board link permissions",
|
||||
"trusted_board_links_intro": "Boards can open these sites without asking.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} hilos desde {{currentTimeFilterName}} — [<1>Mostrar más hilos de la semana pasada</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} quiere abrir {{site}}.",
|
||||
"media_hosting_provider_unavailable": "No disponible desde esta red. Prueba con otra red o una VPN.",
|
||||
"board_link_permissions": "Permisos de enlaces de tableros",
|
||||
"trusted_board_links_intro": "Los tableros pueden abrir estos sitios sin preguntar.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} موضوع از {{currentTimeFilterName}} — [<1>نمایش موضوعات بیشتر از هفته گذشته</1>]",
|
||||
"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 را امتحان کنید."
|
||||
"iframe_challenge_confirm": "{{board}} میخواهد {{site}} را باز کند.",
|
||||
"media_hosting_provider_unavailable": "از این شبکه در دسترس نیست. شبکه دیگری یا یک VPN را امتحان کنید.",
|
||||
"board_link_permissions": "مجوزهای پیوندهای برد",
|
||||
"trusted_board_links_intro": "بردها میتوانند این سایتها را بدون پرسش باز کنند.",
|
||||
"trusted_board_links_empty": "هیچ پیوند برد مورد اعتمادی وجود ندارد.",
|
||||
"trusted_board_link_until_revoked": "تا زمان لغو",
|
||||
"trusted_board_link_checkbox": "همیشه به بردها اجازه دهید این وبسایت را باز کنند",
|
||||
"revoke": "لغو"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} ketjua sitten {{currentTimeFilterName}} — [<1>Näytä lisää ketjuja viime viikolta</1>]",
|
||||
"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:ää."
|
||||
"iframe_challenge_confirm": "{{board}} haluaa avata sivuston {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Ei saatavilla tästä verkosta. Kokeile toista verkkoa tai VPN:ää.",
|
||||
"board_link_permissions": "Taululinkkien käyttöoikeudet",
|
||||
"trusted_board_links_intro": "Taulut voivat avata nämä sivustot kysymättä.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} mga thread mula sa {{currentTimeFilterName}} — [<1>Ipakita ang higit pang mga thread mula sa nakaraang linggo</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "Nais buksan ng {{board}} ang {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Hindi available mula sa network na ito. Subukan ang ibang network o VPN.",
|
||||
"board_link_permissions": "Mga pahintulot sa link ng board",
|
||||
"trusted_board_links_intro": "Maaaring buksan ng mga board ang mga site na ito nang hindi nagtatanong.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} fils depuis {{currentTimeFilterName}} — [<1>Afficher plus de fils de la semaine dernière</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} veut ouvrir {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Indisponible depuis ce réseau. Essayez un autre réseau ou un VPN.",
|
||||
"board_link_permissions": "Autorisations des liens de boards",
|
||||
"trusted_board_links_intro": "Les boards peuvent ouvrir ces sites sans demander.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} אשכולות מאז {{currentTimeFilterName}} — [<1>הצג עוד אשכולות מהשבוע שעבר</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} רוצה לפתוח את {{site}}.",
|
||||
"media_hosting_provider_unavailable": "לא זמין מהרשת הזו. נסו רשת אחרת או VPN.",
|
||||
"board_link_permissions": "הרשאות קישורי לוחות",
|
||||
"trusted_board_links_intro": "לוחות יכולים לפתוח את האתרים האלה בלי לשאול.",
|
||||
"trusted_board_links_empty": "אין קישורי לוחות מהימנים.",
|
||||
"trusted_board_link_until_revoked": "עד לביטול",
|
||||
"trusted_board_link_checkbox": "לאפשר תמיד ללוחות לפתוח את האתר הזה",
|
||||
"revoke": "בטל"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} से — [<1>पिछले सप्ताह के और थ्रेड दिखाएं</1>]",
|
||||
"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 आज़माएँ।"
|
||||
"iframe_challenge_confirm": "{{board}} {{site}} खोलना चाहता है।",
|
||||
"media_hosting_provider_unavailable": "इस नेटवर्क से उपलब्ध नहीं है। कोई दूसरा नेटवर्क या VPN आज़माएँ।",
|
||||
"board_link_permissions": "बोर्ड लिंक अनुमतियां",
|
||||
"trusted_board_links_intro": "बोर्ड बिना पूछे इन साइटों को खोल सकते हैं।",
|
||||
"trusted_board_links_empty": "कोई विश्वसनीय बोर्ड लिंक नहीं।",
|
||||
"trusted_board_link_until_revoked": "रद्द होने तक",
|
||||
"trusted_board_link_checkbox": "बोर्ड को हमेशा यह वेबसाइट खोलने दें",
|
||||
"revoke": "रद्द करें"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} szál a {{currentTimeFilterName}} óta — [<1>Mutass több szálat a múlt héten</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} meg szeretné nyitni a következőt: {{site}}.",
|
||||
"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.",
|
||||
"board_link_permissions": "Táblahivatkozás-engedélyek",
|
||||
"trusted_board_links_intro": "A táblák kérdés nélkül megnyithatják ezeket a webhelyeket.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} thread sejak {{currentTimeFilterName}} — [<1>Show lebih banyak thread dari minggu lalu</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} ingin membuka {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Tidak tersedia dari jaringan ini. Coba jaringan lain atau VPN.",
|
||||
"board_link_permissions": "Izin tautan papan",
|
||||
"trusted_board_links_intro": "Papan dapat membuka situs ini tanpa bertanya.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} thread da {{currentTimeFilterName}} — [<1>Mostra più thread dalla scorsa settimana</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} vuole aprire {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Non disponibile da questa rete. Prova un'altra rete o una VPN.",
|
||||
"board_link_permissions": "Autorizzazioni dei link delle bacheche",
|
||||
"trusted_board_links_intro": "Le bacheche possono aprire questi siti senza chiedere.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} スレッド {{currentTimeFilterName}} から — [<1>先週のスレッドをさらに表示</1>]",
|
||||
"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をお試しください。"
|
||||
"iframe_challenge_confirm": "{{board}}が{{site}}を開こうとしています。",
|
||||
"media_hosting_provider_unavailable": "このネットワークからは利用できません。別のネットワークかVPNをお試しください。",
|
||||
"board_link_permissions": "板リンクの権限",
|
||||
"trusted_board_links_intro": "板は確認なしでこれらのサイトを開けます。",
|
||||
"trusted_board_links_empty": "信頼済みの板リンクはありません。",
|
||||
"trusted_board_link_until_revoked": "取り消すまで",
|
||||
"trusted_board_link_checkbox": "板が常にこのウェブサイトを開くことを許可する",
|
||||
"revoke": "取り消す"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} 스레드 {{currentTimeFilterName}} 이후 — [<1>지난 주의 더 많은 스레드 보기</1>]",
|
||||
"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을 시도해 보세요."
|
||||
"iframe_challenge_confirm": "{{board}}에서 {{site}} 열기를 요청했습니다.",
|
||||
"media_hosting_provider_unavailable": "이 네트워크에서는 사용할 수 없습니다. 다른 네트워크나 VPN을 시도해 보세요.",
|
||||
"board_link_permissions": "게시판 링크 권한",
|
||||
"trusted_board_links_intro": "게시판이 묻지 않고 이 사이트들을 열 수 있습니다.",
|
||||
"trusted_board_links_empty": "신뢰한 게시판 링크가 없습니다.",
|
||||
"trusted_board_link_until_revoked": "취소할 때까지",
|
||||
"trusted_board_link_checkbox": "게시판이 항상 이 웹사이트를 열도록 허용",
|
||||
"revoke": "취소"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} थ्रेड {{currentTimeFilterName}} पासून — [<1>गेल्या आठवड्यातील अधिक थ्रेड्स पहा</1>]",
|
||||
"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 वापरून पाहा."
|
||||
"iframe_challenge_confirm": "{{board}} ला {{site}} उघडायचे आहे.",
|
||||
"media_hosting_provider_unavailable": "या नेटवर्कवरून उपलब्ध नाही. दुसरे नेटवर्क किंवा VPN वापरून पाहा.",
|
||||
"board_link_permissions": "बोर्ड दुवा परवानग्या",
|
||||
"trusted_board_links_intro": "बोर्ड विचारल्याशिवाय या साइट्स उघडू शकतात.",
|
||||
"trusted_board_links_empty": "विश्वसनीय बोर्ड दुवे नाहीत.",
|
||||
"trusted_board_link_until_revoked": "रद्द करेपर्यंत",
|
||||
"trusted_board_link_checkbox": "बोर्डना नेहमी ही वेबसाइट उघडण्याची परवानगी द्या",
|
||||
"revoke": "रद्द करा"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} threads sinds {{currentTimeFilterName}} — [<1>Toon meer threads van vorige week</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} wil {{site}} openen.",
|
||||
"media_hosting_provider_unavailable": "Niet beschikbaar via dit netwerk. Probeer een ander netwerk of een VPN.",
|
||||
"board_link_permissions": "Machtigingen voor boardlinks",
|
||||
"trusted_board_links_intro": "Boards kunnen deze sites openen zonder te vragen.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} tråder siden {{currentTimeFilterName}} — [<1>Vis flere tråder fra forrige uke</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} vil åpne {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Ikke tilgjengelig fra dette nettverket. Prøv et annet nettverk eller en VPN.",
|
||||
"board_link_permissions": "Tillatelser for tavlelenker",
|
||||
"trusted_board_links_intro": "Tavler kan åpne disse nettstedene uten å spørre.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} wątki od {{currentTimeFilterName}} — [<1>Pokaż więcej wątków z zeszłego tygodnia</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} chce otworzyć {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Niedostępne z tej sieci. Spróbuj użyć innej sieci lub VPN.",
|
||||
"board_link_permissions": "Uprawnienia linków tablic",
|
||||
"trusted_board_links_intro": "Tablice mogą otwierać te strony bez pytania.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} tópicos desde {{currentTimeFilterName}} — [<1>Mostrar mais tópicos da semana passada</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} quer abrir {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Indisponível nesta rede. Tente outra rede ou uma VPN.",
|
||||
"board_link_permissions": "Permissões de links de boards",
|
||||
"trusted_board_links_intro": "Os boards podem abrir estes sites sem perguntar.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} fire de discuție de la {{currentTimeFilterName}} — [<1>Arată mai multe fire din săptămâna trecută</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} vrea să deschidă {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Indisponibil din această rețea. Încearcă altă rețea sau un VPN.",
|
||||
"board_link_permissions": "Permisiuni pentru linkurile boardurilor",
|
||||
"trusted_board_links_intro": "Boardurile pot deschide aceste site-uri fără să întrebe.",
|
||||
"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ă"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} тредов с {{currentTimeFilterName}} — [<1>Показать больше тредов за прошлую неделю</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} хочет открыть {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Недоступно из этой сети. Попробуйте другую сеть или VPN.",
|
||||
"board_link_permissions": "Разрешения ссылок досок",
|
||||
"trusted_board_links_intro": "Доски могут открывать эти сайты без запроса.",
|
||||
"trusted_board_links_empty": "Нет доверенных ссылок досок.",
|
||||
"trusted_board_link_until_revoked": "до отзыва",
|
||||
"trusted_board_link_checkbox": "Всегда разрешать доскам открывать этот сайт",
|
||||
"revoke": "Отозвать"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} tema nga {{currentTimeFilterName}} — [<1>Shfaq më shumë tema nga java e kaluar</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} dëshiron të hapë {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Nuk disponohet nga ky rrjet. Provoni një rrjet tjetër ose një VPN.",
|
||||
"board_link_permissions": "Lejet e lidhjeve të bordeve",
|
||||
"trusted_board_links_intro": "Bordet mund t'i hapin këto faqe pa pyetur.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} trådar sedan {{currentTimeFilterName}} — [<1>Visa fler trådar från förra veckan</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} vill öppna {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Inte tillgängligt från det här nätverket. Prova ett annat nätverk eller en VPN.",
|
||||
"board_link_permissions": "Behörigheter för boardlänkar",
|
||||
"trusted_board_links_intro": "Boards kan öppna dessa webbplatser utan att fråga.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} థ్రెడ్లు {{currentTimeFilterName}} నుండి — [<1>గత వారానికి మరిన్ని థ్రెడ్లు చూపించు</1>]",
|
||||
"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 ప్రయత్నించండి."
|
||||
"iframe_challenge_confirm": "{{board}} {{site}} ను తెరవాలని కోరుతోంది.",
|
||||
"media_hosting_provider_unavailable": "ఈ నెట్వర్క్ నుండి అందుబాటులో లేదు. మరో నెట్వర్క్ లేదా VPN ప్రయత్నించండి.",
|
||||
"board_link_permissions": "బోర్డ్ లింక్ అనుమతులు",
|
||||
"trusted_board_links_intro": "బోర్డులు అడగకుండా ఈ సైట్లను తెరవగలవు.",
|
||||
"trusted_board_links_empty": "నమ్మదగిన బోర్డ్ లింకులు లేవు.",
|
||||
"trusted_board_link_until_revoked": "రద్దు చేసే వరకు",
|
||||
"trusted_board_link_checkbox": "బోర్డులు ఎల్లప్పుడూ ఈ వెబ్సైట్ను తెరవడానికి అనుమతించు",
|
||||
"revoke": "రద్దు చేయి"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} กระทู้ตั้งแต่ {{currentTimeFilterName}} — [<1>แสดงกระทู้เพิ่มเติมจากสัปดาห์ที่แล้ว</1>]",
|
||||
"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"
|
||||
"iframe_challenge_confirm": "{{board}} ต้องการเปิด {{site}}",
|
||||
"media_hosting_provider_unavailable": "ไม่สามารถใช้งานได้จากเครือข่ายนี้ ลองใช้เครือข่ายอื่นหรือ VPN",
|
||||
"board_link_permissions": "สิทธิ์ลิงก์บอร์ด",
|
||||
"trusted_board_links_intro": "บอร์ดสามารถเปิดไซต์เหล่านี้ได้โดยไม่ต้องถาม",
|
||||
"trusted_board_links_empty": "ไม่มีลิงก์บอร์ดที่เชื่อถือได้",
|
||||
"trusted_board_link_until_revoked": "จนกว่าจะเพิกถอน",
|
||||
"trusted_board_link_checkbox": "อนุญาตให้บอร์ดเปิดเว็บไซต์นี้เสมอ",
|
||||
"revoke": "เพิกถอน"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} başlık {{currentTimeFilterName}}’den itibaren — [<1>Geçen haftadan daha fazla başlık göster</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}}, {{site}} sitesini açmak istiyor.",
|
||||
"media_hosting_provider_unavailable": "Bu ağdan kullanılamıyor. Başka bir ağ veya VPN deneyin.",
|
||||
"board_link_permissions": "Pano bağlantısı izinleri",
|
||||
"trusted_board_links_intro": "Panolar bu siteleri sormadan açabilir.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} тем з {{currentTimeFilterName}} — [<1>Показати більше тем з минулого тижня</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} хоче відкрити {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Недоступно з цієї мережі. Спробуйте іншу мережу або VPN.",
|
||||
"board_link_permissions": "Дозволи для посилань дощок",
|
||||
"trusted_board_links_intro": "Дошки можуть відкривати ці сайти без запиту.",
|
||||
"trusted_board_links_empty": "Немає довірених посилань дощок.",
|
||||
"trusted_board_link_until_revoked": "до відкликання",
|
||||
"trusted_board_link_checkbox": "Завжди дозволяти дошкам відкривати цей сайт",
|
||||
"revoke": "Відкликати"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} تھریڈ {{currentTimeFilterName}} سے — [<1>گزشتہ ہفتے کے مزید تھریڈ دیکھیں</1>]",
|
||||
"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 آزمائیں۔"
|
||||
"iframe_challenge_confirm": "{{board}}، {{site}} کھولنا چاہتا ہے۔",
|
||||
"media_hosting_provider_unavailable": "اس نیٹ ورک سے دستیاب نہیں۔ کوئی دوسرا نیٹ ورک یا VPN آزمائیں۔",
|
||||
"board_link_permissions": "بورڈ لنک اجازتیں",
|
||||
"trusted_board_links_intro": "بورڈ یہ سائٹس پوچھے بغیر کھول سکتے ہیں۔",
|
||||
"trusted_board_links_empty": "کوئی قابل اعتماد بورڈ لنکس نہیں۔",
|
||||
"trusted_board_link_until_revoked": "منسوخ ہونے تک",
|
||||
"trusted_board_link_checkbox": "بورڈز کو ہمیشہ یہ ویب سائٹ کھولنے کی اجازت دیں",
|
||||
"revoke": "منسوخ کریں"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} chủ đề từ {{currentTimeFilterName}} — [<1>Xem thêm chủ đề từ tuần trước</1>]",
|
||||
"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."
|
||||
"iframe_challenge_confirm": "{{board}} muốn mở {{site}}.",
|
||||
"media_hosting_provider_unavailable": "Không khả dụng từ mạng này. Hãy thử mạng khác hoặc VPN.",
|
||||
"board_link_permissions": "Quyền liên kết bảng",
|
||||
"trusted_board_links_intro": "Các bảng có thể mở những trang này mà không cần hỏi.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -364,6 +364,12 @@
|
||||
"more_threads_last_week": "{{count}} 主题自 {{currentTimeFilterName}} — [<1>显示更多来自上周的主题</1>]",
|
||||
"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。"
|
||||
"iframe_challenge_confirm": "{{board}} 想要打开 {{site}}。",
|
||||
"media_hosting_provider_unavailable": "此网络无法使用。请尝试其他网络或 VPN。",
|
||||
"board_link_permissions": "版块链接权限",
|
||||
"trusted_board_links_intro": "版块可以不再询问而打开这些站点。",
|
||||
"trusted_board_links_empty": "没有受信任的版块链接。",
|
||||
"trusted_board_link_until_revoked": "直到撤销",
|
||||
"trusted_board_link_checkbox": "始终允许版块打开此网站",
|
||||
"revoke": "撤销"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ChallengeModal from '../challenge-modal';
|
||||
import getShortAddress from '../../../lib/get-short-address';
|
||||
import useTrustedBoardUrlPermissionsStore from '../../../stores/use-trusted-board-url-permissions-store';
|
||||
|
||||
(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>;
|
||||
@@ -15,7 +16,6 @@ const testState = vi.hoisted(() => ({
|
||||
address: '0xabc123',
|
||||
},
|
||||
} as Record<string, any>,
|
||||
capacitorPlatform: 'web',
|
||||
challenges: [] as Array<{ challenge: any; id: number }>,
|
||||
commentsByCid: {} as Record<string, { author?: { shortAddress?: string } }>,
|
||||
publicationPreview: 'preview body',
|
||||
@@ -33,7 +33,10 @@ vi.mock('react-i18next', () => ({
|
||||
return `${options?.index}/${options?.total}`;
|
||||
}
|
||||
if (key === 'iframe_challenge_confirm') {
|
||||
return `${options?.board} wants to open ${options?.site}.\n\nFor ${options?.publicationType}: ${options?.excerpt}`;
|
||||
return `${options?.board} wants to open ${options?.site}.\n\nFor {{publicationType}}: {{excerpt}}`;
|
||||
}
|
||||
if (key === 'trusted_board_link_checkbox') {
|
||||
return 'Always allow boards to open this website';
|
||||
}
|
||||
return key;
|
||||
},
|
||||
@@ -45,12 +48,6 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: {
|
||||
getPlatform: () => testState.capacitorPlatform,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/challenge-utils', () => ({
|
||||
getPublicationPreview: () => testState.publicationPreview,
|
||||
getPublicationType: () => testState.publicationType,
|
||||
@@ -146,23 +143,17 @@ const clickButton = async (text: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const setNavigatorValue = (key: keyof Navigator, value: unknown) => {
|
||||
Object.defineProperty(window.navigator, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ChallengeModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} });
|
||||
testState.abandonCurrentChallengeMock.mockReset().mockResolvedValue(undefined);
|
||||
testState.account = {
|
||||
author: {
|
||||
address: '0xabc123',
|
||||
},
|
||||
};
|
||||
testState.capacitorPlatform = 'web';
|
||||
testState.challenges = [];
|
||||
testState.commentsByCid = {
|
||||
'parent-1': {
|
||||
@@ -177,10 +168,6 @@ describe('ChallengeModal', () => {
|
||||
testState.springStartMock.mockReset();
|
||||
testState.theme = 'dark';
|
||||
testState.votePreview = 'upvote';
|
||||
setNavigatorValue('maxTouchPoints', 0);
|
||||
setNavigatorValue('platform', 'MacIntel');
|
||||
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36');
|
||||
setNavigatorValue('vendor', 'Google Inc.');
|
||||
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
@@ -280,7 +267,7 @@ describe('ChallengeModal', () => {
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('opens iframe challenges, injects the theme, and completes them', async () => {
|
||||
it('opens iframe challenges through the custom modal, injects the theme, and completes them', async () => {
|
||||
const publication = createPublication();
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
@@ -293,16 +280,25 @@ describe('ChallengeModal', () => {
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open mintpass.org.\n\nFor post: Subject');
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for post');
|
||||
expect(container.textContent).toContain('mintpass.org');
|
||||
expect(container.textContent).toContain('Done');
|
||||
expect(container.textContent).not.toContain('Challenge for post');
|
||||
expect(container.textContent).not.toContain('Open');
|
||||
expect(container.textContent).toContain('mu wants to open mintpass.org.');
|
||||
expect(container.textContent).not.toContain('For post:');
|
||||
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
|
||||
expect(container.textContent).toContain('Open');
|
||||
expect(container.textContent).toContain('Always allow boards to open this website');
|
||||
expect(container.textContent).not.toContain('Always allow boards to open mintpass.org');
|
||||
expect(container.textContent).not.toContain('Cancel');
|
||||
expect(container.textContent).not.toContain('Alice');
|
||||
expect(container.textContent).not.toContain('Subject');
|
||||
expect(container.textContent).not.toContain('Publication content');
|
||||
expect(container.textContent).not.toContain('mu wants to open');
|
||||
const publicationFields = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="text"]'));
|
||||
expect(publicationFields.map((input) => input.value)).toEqual(['Alice', 'Subject', 'https://example.com/link']);
|
||||
expect(publicationFields.every((input) => input.disabled)).toBe(true);
|
||||
const publicationTextarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||||
expect(publicationTextarea?.value).toBe('Publication content');
|
||||
expect(publicationTextarea?.disabled).toBe(true);
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
@@ -327,6 +323,47 @@ describe('ChallengeModal', () => {
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('remembers a trusted iframe origin when the checkbox is selected', async () => {
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
await act(async () => {
|
||||
checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
await clickButton('Open');
|
||||
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(true);
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins()).toEqual([
|
||||
expect.objectContaining({
|
||||
origin: 'https://spamblocker.bitsocial.net',
|
||||
site: 'spamblocker.bitsocial.net',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens trusted iframe origins without asking again', async () => {
|
||||
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net');
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain('wants to open');
|
||||
expect(container.querySelector('iframe')?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
|
||||
});
|
||||
|
||||
it('allows localhost http iframe challenges for local spam blocker testing', async () => {
|
||||
const publication = createPublication();
|
||||
testState.challenges = [
|
||||
@@ -341,7 +378,12 @@ describe('ChallengeModal', () => {
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open localhost:3000.\n\nFor post: Subject');
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('mu wants to open localhost:3000.');
|
||||
expect(container.textContent).not.toContain('For post:');
|
||||
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
@@ -374,6 +416,7 @@ describe('ChallengeModal', () => {
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
await clickButton('Open');
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
@@ -405,6 +448,7 @@ describe('ChallengeModal', () => {
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
await clickButton('Open');
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
@@ -442,11 +486,14 @@ describe('ChallengeModal', () => {
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000.\n\nFor post: Subject`);
|
||||
expect(confirmSpy.mock.calls[0]?.[0]).not.toContain(longCommunityAddress);
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000.`);
|
||||
expect(container.textContent).not.toContain('For post:');
|
||||
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
|
||||
expect(container.textContent).not.toContain(longCommunityAddress);
|
||||
});
|
||||
|
||||
it('uses reply content in the iframe confirmation excerpt', async () => {
|
||||
it('shows reply content in disabled fields instead of the iframe confirmation message', async () => {
|
||||
const publication = {
|
||||
...createPublication(),
|
||||
content: '>>17\nA reply body with enough context',
|
||||
@@ -465,97 +512,15 @@ describe('ChallengeModal', () => {
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 A reply body with enough context');
|
||||
});
|
||||
|
||||
it('abandons iframe challenges when the external confirmation is canceled', async () => {
|
||||
confirmSpy.mockReturnValueOnce(false);
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open spamblocker.bitsocial.net.\n\nFor post: Subject');
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses inline iframe confirmation on Android instead of window.confirm', async () => {
|
||||
const publication = createPublication();
|
||||
testState.capacitorPlatform = 'android';
|
||||
testState.publicationType = 'reply';
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
{
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
},
|
||||
{
|
||||
...publication,
|
||||
content: '>>17\nreply from android',
|
||||
title: '',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('spamblocker.bitsocial.net');
|
||||
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from android');
|
||||
expect(container.textContent).toContain('Open');
|
||||
expect(container.textContent).toContain('close');
|
||||
expect(container.textContent).not.toContain('Challenge for reply');
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
|
||||
expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for reply');
|
||||
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.');
|
||||
expect(container.textContent).not.toContain('For reply:');
|
||||
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('>>17\nA reply body with enough context');
|
||||
});
|
||||
|
||||
it('uses inline iframe confirmation in Android WebView even when Capacitor reports web', async () => {
|
||||
testState.capacitorPlatform = 'web';
|
||||
setNavigatorValue(
|
||||
'userAgent',
|
||||
'Mozilla/5.0 (Linux; Android 15; Pixel 9 Build/AP3A.241105.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.137 Mobile Safari/537.36 wv',
|
||||
);
|
||||
testState.publicationType = 'reply';
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
{
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
},
|
||||
{
|
||||
...createPublication(),
|
||||
content: '>>17\nreply from webview',
|
||||
title: '',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from webview');
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
|
||||
});
|
||||
|
||||
it('abandons Android iframe challenges when inline confirmation is closed', async () => {
|
||||
testState.capacitorPlatform = 'android';
|
||||
it('abandons iframe challenges when the custom confirmation is closed', async () => {
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
@@ -571,41 +536,8 @@ describe('ChallengeModal', () => {
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses inline iframe confirmation on Safari instead of window.confirm', async () => {
|
||||
testState.publicationType = 'reply';
|
||||
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
|
||||
setNavigatorValue('vendor', 'Apple Computer, Inc.');
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
{
|
||||
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
|
||||
type: 'url/iframe',
|
||||
},
|
||||
{
|
||||
...createPublication(),
|
||||
content: '>>17\nreply from safari',
|
||||
title: '',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from safari');
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
|
||||
});
|
||||
|
||||
it('shows iframe challenge errors inline on Safari instead of relying on alert', async () => {
|
||||
it('shows iframe challenge errors inline instead of relying on alert', async () => {
|
||||
testState.account = { author: { address: '' } };
|
||||
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
|
||||
setNavigatorValue('vendor', 'Apple Computer, Inc.');
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://mintpass.org/auth?user={userAddress}',
|
||||
@@ -614,7 +546,6 @@ describe('ChallengeModal', () => {
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
await clickButton('Open');
|
||||
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
@@ -625,24 +556,7 @@ describe('ChallengeModal', () => {
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('alerts when iframe challenges need a signer address and the account is missing one', async () => {
|
||||
testState.account = { author: { address: '' } };
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://mintpass.org/auth?user={userAddress}',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('abandons invalid iframe challenges and responds to Escape', async () => {
|
||||
it('shows invalid iframe challenge errors inline and still responds to Escape', async () => {
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'http://example.com/unsafe',
|
||||
@@ -652,14 +566,15 @@ describe('ChallengeModal', () => {
|
||||
|
||||
await renderModal();
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
expect(container.textContent).toContain('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
|
||||
expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledTimes(2);
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
}
|
||||
|
||||
.iframeConsentWrapper {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin: 10px 8px;
|
||||
}
|
||||
@@ -124,6 +126,20 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.iframeTrustCheckbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.iframeTrustCheckbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 1px 0 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -138,6 +154,11 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.iframeFooter span {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.iframeInstruction {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Challenge as ChallengeType, useAccount, useComment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { getPublicationPreview, getPublicationType, getVotePreview, type ChallengePublication } from '../../lib/utils/challenge-utils';
|
||||
import { getPublicationPreview, getPublicationType, getVotePreview } from '../../lib/utils/challenge-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import useTrustedBoardUrlPermissionsStore from '../../stores/use-trusted-board-url-permissions-store';
|
||||
import useTheme from '../../hooks/use-theme';
|
||||
import styles from './challenge-modal.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
@@ -19,57 +19,14 @@ const useParentAddress = (parentCid?: string) => {
|
||||
|
||||
interface ChallengeProps {
|
||||
challenge: ChallengeType;
|
||||
challengeId: number;
|
||||
closeModal: () => void;
|
||||
abandonModal: () => void;
|
||||
}
|
||||
|
||||
const MAX_IFRAME_CONFIRM_EXCERPT_LENGTH = 80;
|
||||
|
||||
const getTrimmedExcerpt = (value: unknown) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '');
|
||||
|
||||
const shortenConfirmExcerpt = (excerpt: string) =>
|
||||
excerpt.length > MAX_IFRAME_CONFIRM_EXCERPT_LENGTH ? `${excerpt.slice(0, MAX_IFRAME_CONFIRM_EXCERPT_LENGTH).trimEnd()}...` : excerpt;
|
||||
|
||||
const getIframeConfirmExcerpt = (publication: ChallengePublication | undefined, publicationTarget: ChallengePublication | undefined, publicationType?: string) => {
|
||||
const title = getTrimmedExcerpt(publication?.title);
|
||||
const content = getTrimmedExcerpt(publication?.content);
|
||||
const link = getTrimmedExcerpt(publication?.link);
|
||||
const excerpt =
|
||||
publicationType === 'vote' ? getTrimmedExcerpt(getPublicationPreview(publicationTarget)) : publicationType === 'reply' ? content || link : title || content || link;
|
||||
|
||||
return shortenConfirmExcerpt(excerpt || getTrimmedExcerpt(getPublicationPreview(publication)));
|
||||
};
|
||||
|
||||
const getDisplayCommunityAddress = (shortCommunityAddress?: string, communityAddress?: string) =>
|
||||
shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || '';
|
||||
|
||||
const iframeChallengeConfirmDecisions = new Map<string, 'accepted' | 'rejected'>();
|
||||
const ANDROID_USER_AGENT_REGEX = /Android/;
|
||||
const ANDROID_WEBVIEW_USER_AGENT_REGEX = /\bwv\b|Version\/\d+(?:\.\d+)?\s+Chrome\//;
|
||||
|
||||
const isAndroidWebViewUserAgent = () => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return ANDROID_USER_AGENT_REGEX.test(navigator.userAgent) && ANDROID_WEBVIEW_USER_AGENT_REGEX.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
const isIosWebKitUserAgent = () => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
};
|
||||
|
||||
const isDesktopSafariUserAgent = () => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const isAppleVendor = navigator.vendor === 'Apple Computer, Inc.';
|
||||
const isSafari = /Safari/.test(navigator.userAgent);
|
||||
const isOtherBrowser = /Chrome|Chromium|CriOS|FxiOS|Edg|OPR|SamsungBrowser/.test(navigator.userAgent);
|
||||
return isAppleVendor && isSafari && !isOtherBrowser;
|
||||
};
|
||||
|
||||
const shouldUseInlineIframeConfirm = () => {
|
||||
const platform = Capacitor.getPlatform();
|
||||
return platform === 'android' || platform === 'ios' || isAndroidWebViewUserAgent() || isIosWebKitUserAgent() || isDesktopSafariUserAgent();
|
||||
};
|
||||
const stripLegacyIframeConfirmDetails = (message: string) => message.split(/\r?\n\s*\r?\n/)[0] ?? message;
|
||||
|
||||
const TextChallenge = ({ challenge }: { challenge: string }) => <div className={styles.challengeMedia}>{challenge}</div>;
|
||||
|
||||
@@ -136,36 +93,38 @@ const getIframeSessionId = (challengeUrl: string) => {
|
||||
|
||||
interface IframeChallengeProps {
|
||||
challenge: string;
|
||||
confirmKey: string;
|
||||
confirmMessage: string;
|
||||
inlineConfirm: boolean;
|
||||
onCancel: () => void;
|
||||
onDone: () => void;
|
||||
onAutoComplete: (challengeAnswers: string[]) => void;
|
||||
onReady: () => void;
|
||||
openLabel: string;
|
||||
closeLabel: string;
|
||||
rememberPermissionLabel: string;
|
||||
publicationDetails: React.ReactNode;
|
||||
}
|
||||
|
||||
type IframeConfirmSource = 'native' | 'inline';
|
||||
type IframeOpenMode = 'trusted' | 'manual';
|
||||
|
||||
const IframeChallenge = ({
|
||||
challenge,
|
||||
confirmKey,
|
||||
confirmMessage,
|
||||
inlineConfirm,
|
||||
onCancel,
|
||||
onDone,
|
||||
onAutoComplete,
|
||||
onReady,
|
||||
openLabel,
|
||||
closeLabel,
|
||||
rememberPermissionLabel,
|
||||
publicationDetails,
|
||||
}: IframeChallengeProps) => {
|
||||
const account = useAccount();
|
||||
const [theme] = useTheme();
|
||||
const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin);
|
||||
const [iframeUrlState, setIframeUrl] = useState('');
|
||||
const [iframeOrigin, setIframeOrigin] = useState('');
|
||||
const [inlineErrorMessage, setInlineErrorMessage] = useState('');
|
||||
const [rememberPermission, setRememberPermission] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const attemptedLoadRef = useRef(false);
|
||||
const mountedRef = useRef(false);
|
||||
@@ -192,18 +151,14 @@ const IframeChallenge = ({
|
||||
);
|
||||
|
||||
const handleLoadIframe = useCallback(
|
||||
(confirmSource: IframeConfirmSource) => {
|
||||
(mode: IframeOpenMode) => {
|
||||
if (attemptedLoadRef.current) return;
|
||||
const iframeUrl = challenge;
|
||||
if (!iframeUrl) return;
|
||||
|
||||
const rejectChallenge = (message: string) => {
|
||||
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
|
||||
if (inlineConfirm) {
|
||||
setInlineErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
alert(message);
|
||||
onCancel();
|
||||
attemptedLoadRef.current = true;
|
||||
setInlineErrorMessage(message);
|
||||
};
|
||||
|
||||
const rawUserAddress = account?.author?.address?.trim();
|
||||
@@ -227,41 +182,31 @@ const IframeChallenge = ({
|
||||
rejectChallenge('Error: Invalid URL for authentication challenge');
|
||||
return;
|
||||
}
|
||||
const decision = iframeChallengeConfirmDecisions.get(confirmKey);
|
||||
if (decision === 'rejected') {
|
||||
onCancel();
|
||||
|
||||
const isTrusted = useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted(validatedUrl.origin);
|
||||
if (!isTrusted && mode === 'trusted') {
|
||||
return;
|
||||
}
|
||||
if (decision !== 'accepted') {
|
||||
if (confirmSource === 'native' && !window.confirm(confirmMessage)) {
|
||||
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
iframeChallengeConfirmDecisions.set(confirmKey, 'accepted');
|
||||
attemptedLoadRef.current = true;
|
||||
if (!isTrusted && rememberPermission) {
|
||||
trustOrigin(validatedUrl.origin, getReadableIframeUrl(replacedUrl), null);
|
||||
}
|
||||
openValidatedIframe(validatedUrl);
|
||||
},
|
||||
[account, challenge, confirmKey, confirmMessage, inlineConfirm, onCancel, openValidatedIframe, theme],
|
||||
[account, challenge, openValidatedIframe, rememberPermission, theme, trustOrigin],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (inlineConfirm) return;
|
||||
if (attemptedLoadRef.current) return;
|
||||
attemptedLoadRef.current = true;
|
||||
handleLoadIframe('native');
|
||||
}, [handleLoadIframe, inlineConfirm]);
|
||||
handleLoadIframe('trusted');
|
||||
}, [handleLoadIframe]);
|
||||
|
||||
const handleInlineConfirm = useCallback(() => {
|
||||
if (attemptedLoadRef.current) return;
|
||||
attemptedLoadRef.current = true;
|
||||
handleLoadIframe('inline');
|
||||
handleLoadIframe('manual');
|
||||
}, [handleLoadIframe]);
|
||||
|
||||
const handleInlineCancel = useCallback(() => {
|
||||
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
|
||||
onCancel();
|
||||
}, [confirmKey, onCancel]);
|
||||
}, [onCancel]);
|
||||
|
||||
const sendThemeToIframe = useCallback(() => {
|
||||
postThemeToIframe(iframeRef.current, iframeOrigin, theme);
|
||||
@@ -302,28 +247,32 @@ const IframeChallenge = ({
|
||||
}, [expectedSessionId, iframeOrigin, onAutoComplete]);
|
||||
|
||||
if (!iframeUrlState) {
|
||||
if (inlineConfirm) {
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeConsentWrapper}`}>
|
||||
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<span>
|
||||
{!inlineErrorMessage && (
|
||||
<button type='button' onClick={handleInlineConfirm}>
|
||||
{openLabel}
|
||||
</button>
|
||||
)}
|
||||
<button type='button' onClick={handleInlineCancel}>
|
||||
{closeLabel}
|
||||
return (
|
||||
<>
|
||||
{publicationDetails}
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeConsentWrapper}`}>
|
||||
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
|
||||
{!inlineErrorMessage && (
|
||||
<label className={styles.iframeTrustCheckbox}>
|
||||
<input type='checkbox' checked={rememberPermission} onChange={(event) => setRememberPermission(event.target.checked)} />
|
||||
{rememberPermissionLabel}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<span>
|
||||
{!inlineErrorMessage && (
|
||||
<button type='button' onClick={handleInlineConfirm}>
|
||||
{openLabel}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
)}
|
||||
<button type='button' onClick={handleInlineCancel}>
|
||||
{closeLabel}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -347,7 +296,7 @@ const IframeChallenge = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: ChallengeProps) => {
|
||||
const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const challenges = challenge?.[0]?.challenges;
|
||||
@@ -377,12 +326,10 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
|
||||
}));
|
||||
|
||||
const currentChallenge = challenges?.[currentChallengeIndex];
|
||||
const iframeChallengeKey = `${challengeId}:${currentChallengeIndex}:${currentChallenge?.challenge ?? ''}`;
|
||||
const iframeChallengeKey = `${currentChallengeIndex}:${currentChallenge?.challenge ?? ''}`;
|
||||
const isTextChallenge = currentChallenge?.type === 'text/plain';
|
||||
const isImageChallenge = currentChallenge?.type === 'image/png';
|
||||
const isIframeChallenge = currentChallenge?.type === 'url/iframe';
|
||||
const inlineIframeConfirm = isIframeChallenge && shouldUseInlineIframeConfirm();
|
||||
const isIframePending = isIframeChallenge && !inlineIframeConfirm && readyIframeChallengeKey !== iframeChallengeKey;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -474,7 +421,7 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
|
||||
return null;
|
||||
}
|
||||
|
||||
const isIframeVisible = isIframeChallenge;
|
||||
const isIframeVisible = isIframeChallenge && readyIframeChallengeKey === iframeChallengeKey;
|
||||
|
||||
const containerClasses = [styles.container];
|
||||
if (isIframeVisible) {
|
||||
@@ -490,14 +437,14 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
|
||||
const mobileX = isIframeVisible ? 5 : window.innerWidth / 2 - 150;
|
||||
const mobileY = isIframeVisible ? Math.max(10, (window.innerHeight - 600) / 2) : window.innerHeight / 2 - 200;
|
||||
const displayCommunityAddress = getDisplayCommunityAddress(shortCommunityAddress, communityAddress) || t('board');
|
||||
const iframeConfirmMessage = t('iframe_challenge_confirm', {
|
||||
board: displayCommunityAddress,
|
||||
excerpt: getIframeConfirmExcerpt(publication, publicationTarget, publicationType) || t('none'),
|
||||
interpolation: { escapeValue: false },
|
||||
publicationType: publicationType ? t(publicationType) : t('post'),
|
||||
site: readableUrl || t('webpage'),
|
||||
});
|
||||
const challengeTitle = isIframeChallenge ? readableUrl || t('iframe') : `Challenge for ${publicationType}`;
|
||||
const iframeConfirmMessage = stripLegacyIframeConfirmDetails(
|
||||
t('iframe_challenge_confirm', {
|
||||
board: displayCommunityAddress,
|
||||
interpolation: { escapeValue: false },
|
||||
site: readableUrl || t('webpage'),
|
||||
}),
|
||||
);
|
||||
const challengeTitle = `Challenge for ${publicationType || t('post')}`;
|
||||
|
||||
const publicationDetails = (
|
||||
<>
|
||||
@@ -532,7 +479,6 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
|
||||
style={{
|
||||
x: isMobile ? mobileX : x.to((value) => Math.round(value)),
|
||||
y: isMobile ? mobileY : y.to((value) => Math.round(value)),
|
||||
display: isIframePending ? 'none' : undefined,
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
@@ -545,15 +491,15 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
|
||||
<IframeChallenge
|
||||
key={currentChallengeIndex}
|
||||
challenge={currentChallenge?.challenge ?? ''}
|
||||
confirmKey={iframeChallengeKey}
|
||||
confirmMessage={iframeConfirmMessage}
|
||||
inlineConfirm={inlineIframeConfirm}
|
||||
onCancel={abandonModal}
|
||||
onDone={onIframeDone}
|
||||
onAutoComplete={onIframeAutoComplete}
|
||||
onReady={onIframeReady}
|
||||
openLabel={capitalize(t('open'))}
|
||||
closeLabel={t('close')}
|
||||
rememberPermissionLabel={t('trusted_board_link_checkbox')}
|
||||
publicationDetails={publicationDetails}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -611,7 +557,7 @@ const ChallengeModal = () => {
|
||||
const challenge = current?.challenge;
|
||||
const challengeId = current?.id ?? 0;
|
||||
|
||||
return isOpen && challenge ? <Challenge key={challengeId} challenge={challenge} challengeId={challengeId} closeModal={closeModal} abandonModal={abandonModal} /> : null;
|
||||
return isOpen && challenge ? <Challenge key={challengeId} challenge={challenge} closeModal={closeModal} abandonModal={abandonModal} /> : null;
|
||||
};
|
||||
|
||||
export default ChallengeModal;
|
||||
|
||||
@@ -42,6 +42,10 @@ vi.mock('../subscriptions-setting', () => ({
|
||||
default: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../trusted-board-links-setting', () => ({
|
||||
default: () => <div data-testid='trusted-board-links-settings-panel'>trusted-board-links-settings</div>,
|
||||
}));
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid='location'>{location.pathname + location.hash}</div>;
|
||||
@@ -133,6 +137,7 @@ describe('SettingsModal', () => {
|
||||
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
|
||||
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();
|
||||
|
||||
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
|
||||
@@ -150,6 +155,7 @@ describe('SettingsModal', () => {
|
||||
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,16 @@ import InterfaceSettings from './interface-settings';
|
||||
import MediaHostingSettings from './media-hosting-settings';
|
||||
import AdvancedSettings from './advanced-settings';
|
||||
import SubscriptionsSetting from './subscriptions-setting';
|
||||
import TrustedBoardLinksSetting from './trusted-board-links-setting';
|
||||
|
||||
const allSectionIds = ['interface-settings', 'media-hosting-settings', 'account-settings', 'subscriptions-settings', 'advanced-settings'];
|
||||
const allSectionIds = [
|
||||
'interface-settings',
|
||||
'media-hosting-settings',
|
||||
'account-settings',
|
||||
'subscriptions-settings',
|
||||
'board-link-permissions-settings',
|
||||
'advanced-settings',
|
||||
];
|
||||
|
||||
const hashToSection = (hash: string): string | null => {
|
||||
if (hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings') return 'account-settings';
|
||||
@@ -58,6 +66,7 @@ const SettingsModal = () => {
|
||||
const showMediaHostingSettings = visibleExpandedSections.has('media-hosting-settings');
|
||||
const showAccountSettings = visibleExpandedSections.has('account-settings');
|
||||
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
|
||||
const showBoardLinkPermissionsSettings = visibleExpandedSections.has('board-link-permissions-settings');
|
||||
const showAdvancedSettings = visibleExpandedSections.has('advanced-settings');
|
||||
|
||||
const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]);
|
||||
@@ -162,6 +171,13 @@ const SettingsModal = () => {
|
||||
</label>
|
||||
</div>
|
||||
{showSubscriptionsSettings && <SubscriptionsSetting />}
|
||||
<div id='board-link-permissions-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('board-link-permissions-settings')}>
|
||||
<span className={showBoardLinkPermissionsSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('board_link_permissions')}
|
||||
</label>
|
||||
</div>
|
||||
{showBoardLinkPermissionsSettings && <TrustedBoardLinksSetting />}
|
||||
<div id='advanced-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('advanced-settings')}>
|
||||
<span className={showAdvancedSettings ? styles.hideButton : styles.showButton} />
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
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';
|
||||
import useTrustedBoardUrlPermissionsStore from '../../../../stores/use-trusted-board-url-permissions-store';
|
||||
import TrustedBoardLinksSetting from '../trusted-board-links-setting';
|
||||
|
||||
(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>;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = () => {
|
||||
act(() => {
|
||||
root.render(createElement(TrustedBoardLinksSetting));
|
||||
});
|
||||
};
|
||||
|
||||
describe('TrustedBoardLinksSetting', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} });
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows an empty state when no board links are trusted', () => {
|
||||
render();
|
||||
|
||||
expect(container.textContent).toContain('trusted_board_links_intro');
|
||||
expect(container.textContent).toContain('trusted_board_links_empty');
|
||||
});
|
||||
|
||||
it('revokes a trusted board link permission', async () => {
|
||||
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net');
|
||||
|
||||
render();
|
||||
|
||||
expect(container.textContent).toContain('spamblocker.bitsocial.net');
|
||||
|
||||
const revokeButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'revoke');
|
||||
await act(async () => {
|
||||
revokeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(false);
|
||||
expect(container.textContent).toContain('trusted_board_links_empty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './trusted-board-links-setting';
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.trustedBoardLinks {
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.description, .empty {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.site {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
white-space: nowrap;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useTrustedBoardUrlPermissionsStore from '../../../stores/use-trusted-board-url-permissions-store';
|
||||
import styles from './trusted-board-links-setting.module.css';
|
||||
|
||||
const TrustedBoardLinksSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
const trustedOriginsByOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustedOrigins);
|
||||
const revokeOrigin = useTrustedBoardUrlPermissionsStore((state) => state.revokeOrigin);
|
||||
const trustedOrigins = useMemo(
|
||||
() => Object.values(trustedOriginsByOrigin).sort((a, b) => a.site.localeCompare(b.site) || a.origin.localeCompare(b.origin)),
|
||||
[trustedOriginsByOrigin],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.trustedBoardLinks}>
|
||||
<div className={styles.description}>{t('trusted_board_links_intro')}</div>
|
||||
{trustedOrigins.length ? (
|
||||
<ul className={styles.list}>
|
||||
{trustedOrigins.map((permission) => (
|
||||
<li key={permission.origin} className={styles.item}>
|
||||
<span className={styles.site}>
|
||||
{permission.site} <span className={styles.expiry}>({t('trusted_board_link_until_revoked')})</span>
|
||||
</span>
|
||||
<button type='button' onClick={() => revokeOrigin(permission.origin)}>
|
||||
{t('revoke')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className={styles.empty}>{t('trusted_board_links_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TrustedBoardLinksSetting);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useTrustedBoardUrlPermissionsStore from '../use-trusted-board-url-permissions-store';
|
||||
|
||||
const STORAGE_KEY = 'trusted-board-url-permissions-store';
|
||||
|
||||
describe('useTrustedBoardUrlPermissionsStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} });
|
||||
});
|
||||
|
||||
it('starts without trusted origins', () => {
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins()).toEqual([]);
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts and persists a board challenge origin', () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
|
||||
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net', null, 1_000);
|
||||
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net', 2_000)).toBe(true);
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins(2_000)).toEqual([
|
||||
{
|
||||
createdAt: 1_000,
|
||||
expiresAt: null,
|
||||
origin: 'https://spamblocker.bitsocial.net',
|
||||
site: 'spamblocker.bitsocial.net',
|
||||
},
|
||||
]);
|
||||
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('spamblocker.bitsocial.net'));
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('revokes a trusted origin', () => {
|
||||
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net');
|
||||
|
||||
useTrustedBoardUrlPermissionsStore.getState().revokeOrigin('https://spamblocker.bitsocial.net');
|
||||
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(false);
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins()).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops expired permissions when checked', () => {
|
||||
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://old.example', 'old.example', 10, 1);
|
||||
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://old.example', 11)).toBe(false);
|
||||
expect(useTrustedBoardUrlPermissionsStore.getState().trustedOrigins).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface TrustedBoardUrlPermission {
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
origin: string;
|
||||
site: string;
|
||||
}
|
||||
|
||||
interface TrustedBoardUrlPermissionsState {
|
||||
trustedOrigins: Record<string, TrustedBoardUrlPermission>;
|
||||
clearAll: () => void;
|
||||
getTrustedOrigins: (now?: number) => TrustedBoardUrlPermission[];
|
||||
isOriginTrusted: (origin: string, now?: number) => boolean;
|
||||
revokeOrigin: (origin: string) => void;
|
||||
trustOrigin: (origin: string, site: string, expiresAt?: number | null, now?: number) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'trusted-board-url-permissions-store';
|
||||
|
||||
const isPermissionActive = (permission: TrustedBoardUrlPermission | undefined, now: number) =>
|
||||
!!permission && (permission.expiresAt === null || permission.expiresAt > now);
|
||||
|
||||
const removeExpiredPermissions = (trustedOrigins: Record<string, TrustedBoardUrlPermission>, now: number) =>
|
||||
Object.fromEntries(Object.entries(trustedOrigins).filter(([, permission]) => isPermissionActive(permission, now)));
|
||||
|
||||
const useTrustedBoardUrlPermissionsStore = create<TrustedBoardUrlPermissionsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
trustedOrigins: {},
|
||||
clearAll: () => set({ trustedOrigins: {} }),
|
||||
getTrustedOrigins: (now = Date.now()) =>
|
||||
Object.values(removeExpiredPermissions(get().trustedOrigins, now)).sort((a, b) => a.site.localeCompare(b.site) || a.origin.localeCompare(b.origin)),
|
||||
isOriginTrusted: (origin, now = Date.now()) => {
|
||||
const permission = get().trustedOrigins[origin];
|
||||
if (isPermissionActive(permission, now)) return true;
|
||||
if (permission) {
|
||||
set((state) => {
|
||||
const { [origin]: _expired, ...trustedOrigins } = state.trustedOrigins;
|
||||
return { trustedOrigins };
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
revokeOrigin: (origin) =>
|
||||
set((state) => {
|
||||
const { [origin]: _removed, ...trustedOrigins } = state.trustedOrigins;
|
||||
return { trustedOrigins };
|
||||
}),
|
||||
trustOrigin: (origin, site, expiresAt = null, now = Date.now()) =>
|
||||
set((state) => ({
|
||||
trustedOrigins: {
|
||||
...state.trustedOrigins,
|
||||
[origin]: {
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
origin,
|
||||
site,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
partialize: (state) => ({ trustedOrigins: state.trustedOrigins }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export default useTrustedBoardUrlPermissionsStore;
|
||||
Reference in New Issue
Block a user