mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(quotes): resolve external quote links across boards (#1064)
This commit is contained in:
@@ -300,5 +300,12 @@
|
|||||||
"purged": "مُزال",
|
"purged": "مُزال",
|
||||||
"this_post_was_purged": "جارٍ الحذف من قاعدة بيانات اللوحة — هذه العملية لا يمكن التراجع عنها.",
|
"this_post_was_purged": "جارٍ الحذف من قاعدة بيانات اللوحة — هذه العملية لا يمكن التراجع عنها.",
|
||||||
"news": "الأخبار",
|
"news": "الأخبار",
|
||||||
"save_advanced_settings": "حفظ الإعدادات المتقدمة"
|
"save_advanced_settings": "حفظ الإعدادات المتقدمة",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "অপসারিত",
|
"purged": "অপসারিত",
|
||||||
"this_post_was_purged": "বোর্ড ডাটাবেস থেকে মুছে ফেলা হচ্ছে — এই প্রক্রিয়া অপরিবর্তনীয়।",
|
"this_post_was_purged": "বোর্ড ডাটাবেস থেকে মুছে ফেলা হচ্ছে — এই প্রক্রিয়া অপরিবর্তনীয়।",
|
||||||
"news": "সংবাদ",
|
"news": "সংবাদ",
|
||||||
"save_advanced_settings": "উন্নত সেটিংস সংরক্ষণ করুন"
|
"save_advanced_settings": "উন্নত সেটিংস সংরক্ষণ করুন",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "vymazáno",
|
"purged": "vymazáno",
|
||||||
"this_post_was_purged": "Mažeme z databáze desky — tento proces je nevratný.",
|
"this_post_was_purged": "Mažeme z databáze desky — tento proces je nevratný.",
|
||||||
"news": "Novinky",
|
"news": "Novinky",
|
||||||
"save_advanced_settings": "Uložit pokročilá nastavení"
|
"save_advanced_settings": "Uložit pokročilá nastavení",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "slettet",
|
"purged": "slettet",
|
||||||
"this_post_was_purged": "Sletter fra board-databasen — denne proces er irreversibel.",
|
"this_post_was_purged": "Sletter fra board-databasen — denne proces er irreversibel.",
|
||||||
"news": "Nyheder",
|
"news": "Nyheder",
|
||||||
"save_advanced_settings": "Gem avancerede indstillinger"
|
"save_advanced_settings": "Gem avancerede indstillinger",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "gelöscht",
|
"purged": "gelöscht",
|
||||||
"this_post_was_purged": "Wird aus der Board-Datenbank gelöscht — dieser Vorgang ist unwiderruflich.",
|
"this_post_was_purged": "Wird aus der Board-Datenbank gelöscht — dieser Vorgang ist unwiderruflich.",
|
||||||
"news": "Nachrichten",
|
"news": "Nachrichten",
|
||||||
"save_advanced_settings": "Erweiterte Einstellungen speichern"
|
"save_advanced_settings": "Erweiterte Einstellungen speichern",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "διαγράφηκε",
|
"purged": "διαγράφηκε",
|
||||||
"this_post_was_purged": "Διαγραφή από τη βάση δεδομένων του board — αυτή η διαδικασία είναι μη αναστρέψιμη.",
|
"this_post_was_purged": "Διαγραφή από τη βάση δεδομένων του board — αυτή η διαδικασία είναι μη αναστρέψιμη.",
|
||||||
"news": "Ειδήσεις",
|
"news": "Ειδήσεις",
|
||||||
"save_advanced_settings": "Αποθήκευση προχωρημένων ρυθμίσεων"
|
"save_advanced_settings": "Αποθήκευση προχωρημένων ρυθμίσεων",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"import_account_backup": "Import Account Backup",
|
"import_account_backup": "Import Account Backup",
|
||||||
"boards_you_moderate_nav": "Boards You Moderate",
|
"boards_you_moderate_nav": "Boards You Moderate",
|
||||||
"link_to_file": "Link To File",
|
"link_to_file": "Link To File",
|
||||||
"news": "News"
|
"news": "News",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "purgado",
|
"purged": "purgado",
|
||||||
"this_post_was_purged": "Eliminando de la base de datos del tablón — este proceso es irreversible.",
|
"this_post_was_purged": "Eliminando de la base de datos del tablón — este proceso es irreversible.",
|
||||||
"news": "Noticias",
|
"news": "Noticias",
|
||||||
"save_advanced_settings": "Guardar configuración avanzada"
|
"save_advanced_settings": "Guardar configuración avanzada",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "پاکشده",
|
"purged": "پاکشده",
|
||||||
"this_post_was_purged": "در حال حذف از پایگاه داده تابلو — این فرآیند برگشتناپذیر است.",
|
"this_post_was_purged": "در حال حذف از پایگاه داده تابلو — این فرآیند برگشتناپذیر است.",
|
||||||
"news": "اخبار",
|
"news": "اخبار",
|
||||||
"save_advanced_settings": "ذخیره تنظیمات پیشرفته"
|
"save_advanced_settings": "ذخیره تنظیمات پیشرفته",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "tyhjennetty",
|
"purged": "tyhjennetty",
|
||||||
"this_post_was_purged": "Poistetaan taulun tietokannasta — tämä prosessi on peruuttamaton.",
|
"this_post_was_purged": "Poistetaan taulun tietokannasta — tämä prosessi on peruuttamaton.",
|
||||||
"news": "Uutiset",
|
"news": "Uutiset",
|
||||||
"save_advanced_settings": "Tallenna lisäasetukset"
|
"save_advanced_settings": "Tallenna lisäasetukset",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "nabura",
|
"purged": "nabura",
|
||||||
"this_post_was_purged": "Tinatanggal sa database ng board — hindi na mababawi ang prosesong ito.",
|
"this_post_was_purged": "Tinatanggal sa database ng board — hindi na mababawi ang prosesong ito.",
|
||||||
"news": "Balita",
|
"news": "Balita",
|
||||||
"save_advanced_settings": "I-save ang Mga Advanced na Setting"
|
"save_advanced_settings": "I-save ang Mga Advanced na Setting",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "purgé",
|
"purged": "purgé",
|
||||||
"this_post_was_purged": "Suppression de la base de données du board — ce processus est irréversible.",
|
"this_post_was_purged": "Suppression de la base de données du board — ce processus est irréversible.",
|
||||||
"news": "Actualités",
|
"news": "Actualités",
|
||||||
"save_advanced_settings": "Enregistrer les paramètres avancés"
|
"save_advanced_settings": "Enregistrer les paramètres avancés",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "נוקה",
|
"purged": "נוקה",
|
||||||
"this_post_was_purged": "מנקה ממסד הנתונים של הלוח — תהליך זה אינו הפיך.",
|
"this_post_was_purged": "מנקה ממסד הנתונים של הלוח — תהליך זה אינו הפיך.",
|
||||||
"news": "חדשות",
|
"news": "חדשות",
|
||||||
"save_advanced_settings": "שמור הגדרות מתקדמות"
|
"save_advanced_settings": "שמור הגדרות מתקדמות",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "हटाया गया",
|
"purged": "हटाया गया",
|
||||||
"this_post_was_purged": "बोर्ड डेटाबेस से हटाया जा रहा है — यह प्रक्रिया अपरिवर्तनीय है।",
|
"this_post_was_purged": "बोर्ड डेटाबेस से हटाया जा रहा है — यह प्रक्रिया अपरिवर्तनीय है।",
|
||||||
"news": "समाचार",
|
"news": "समाचार",
|
||||||
"save_advanced_settings": "उन्नत सेटिंग्स सहेजें"
|
"save_advanced_settings": "उन्नत सेटिंग्स सहेजें",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "törölve",
|
"purged": "törölve",
|
||||||
"this_post_was_purged": "Törlés a tábla adatbázisából — ez a folyamat visszafordíthatatlan.",
|
"this_post_was_purged": "Törlés a tábla adatbázisából — ez a folyamat visszafordíthatatlan.",
|
||||||
"news": "Hírek",
|
"news": "Hírek",
|
||||||
"save_advanced_settings": "Speciális beállítások mentése"
|
"save_advanced_settings": "Speciális beállítások mentése",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "dihapus",
|
"purged": "dihapus",
|
||||||
"this_post_was_purged": "Menghapus dari database board — proses ini tidak dapat dibatalkan.",
|
"this_post_was_purged": "Menghapus dari database board — proses ini tidak dapat dibatalkan.",
|
||||||
"news": "Berita",
|
"news": "Berita",
|
||||||
"save_advanced_settings": "Simpan Pengaturan Lanjutan"
|
"save_advanced_settings": "Simpan Pengaturan Lanjutan",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "eliminato",
|
"purged": "eliminato",
|
||||||
"this_post_was_purged": "Eliminazione dal database del board — questo processo è irreversibile.",
|
"this_post_was_purged": "Eliminazione dal database del board — questo processo è irreversibile.",
|
||||||
"news": "Notizie",
|
"news": "Notizie",
|
||||||
"save_advanced_settings": "Salva impostazioni avanzate"
|
"save_advanced_settings": "Salva impostazioni avanzate",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "削除済み",
|
"purged": "削除済み",
|
||||||
"this_post_was_purged": "ボードデータベースから削除中 — この操作は取り消せません。",
|
"this_post_was_purged": "ボードデータベースから削除中 — この操作は取り消せません。",
|
||||||
"news": "ニュース",
|
"news": "ニュース",
|
||||||
"save_advanced_settings": "詳細設定を保存"
|
"save_advanced_settings": "詳細設定を保存",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "삭제됨",
|
"purged": "삭제됨",
|
||||||
"this_post_was_purged": "보드 데이터베이스에서 삭제 중 — 이 작업은 되돌릴 수 없습니다.",
|
"this_post_was_purged": "보드 데이터베이스에서 삭제 중 — 이 작업은 되돌릴 수 없습니다.",
|
||||||
"news": "뉴스",
|
"news": "뉴스",
|
||||||
"save_advanced_settings": "고급 설정 저장"
|
"save_advanced_settings": "고급 설정 저장",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "काढून टाकले",
|
"purged": "काढून टाकले",
|
||||||
"this_post_was_purged": "बोर्ड डेटाबेसमधून काढून टाकत आहे — ही प्रक्रिया अपरिवर्तनीय आहे.",
|
"this_post_was_purged": "बोर्ड डेटाबेसमधून काढून टाकत आहे — ही प्रक्रिया अपरिवर्तनीय आहे.",
|
||||||
"news": "बातम्या",
|
"news": "बातम्या",
|
||||||
"save_advanced_settings": "प्रगत सेटिंग्ज जतवा"
|
"save_advanced_settings": "प्रगत सेटिंग्ज जतवा",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "verwijderd",
|
"purged": "verwijderd",
|
||||||
"this_post_was_purged": "Verwijderen uit board-database — dit proces is onomkeerbaar.",
|
"this_post_was_purged": "Verwijderen uit board-database — dit proces is onomkeerbaar.",
|
||||||
"news": "Nieuws",
|
"news": "Nieuws",
|
||||||
"save_advanced_settings": "Geavanceerde instellingen opslaan"
|
"save_advanced_settings": "Geavanceerde instellingen opslaan",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "slettet",
|
"purged": "slettet",
|
||||||
"this_post_was_purged": "Sletter fra board-databasen — denne prosessen er irreversibel.",
|
"this_post_was_purged": "Sletter fra board-databasen — denne prosessen er irreversibel.",
|
||||||
"news": "Nyheter",
|
"news": "Nyheter",
|
||||||
"save_advanced_settings": "Lagre avanserte innstillinger"
|
"save_advanced_settings": "Lagre avanserte innstillinger",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "usunięty",
|
"purged": "usunięty",
|
||||||
"this_post_was_purged": "Usuwanie z bazy danych tablicy — ten proces jest nieodwracalny.",
|
"this_post_was_purged": "Usuwanie z bazy danych tablicy — ten proces jest nieodwracalny.",
|
||||||
"news": "Aktualności",
|
"news": "Aktualności",
|
||||||
"save_advanced_settings": "Zapisz ustawienia zaawansowane"
|
"save_advanced_settings": "Zapisz ustawienia zaawansowane",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "purgado",
|
"purged": "purgado",
|
||||||
"this_post_was_purged": "Removendo do banco de dados do board — este processo é irreversível.",
|
"this_post_was_purged": "Removendo do banco de dados do board — este processo é irreversível.",
|
||||||
"news": "Notícias",
|
"news": "Notícias",
|
||||||
"save_advanced_settings": "Salvar configurações avançadas"
|
"save_advanced_settings": "Salvar configurações avançadas",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "șters",
|
"purged": "șters",
|
||||||
"this_post_was_purged": "Ștergere din baza de date a board-ului — acest proces este ireversibil.",
|
"this_post_was_purged": "Ștergere din baza de date a board-ului — acest proces este ireversibil.",
|
||||||
"news": "Știri",
|
"news": "Știri",
|
||||||
"save_advanced_settings": "Salvează setările avansate"
|
"save_advanced_settings": "Salvează setările avansate",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "удалено",
|
"purged": "удалено",
|
||||||
"this_post_was_purged": "Удаление из базы данных доски — этот процесс необратим.",
|
"this_post_was_purged": "Удаление из базы данных доски — этот процесс необратим.",
|
||||||
"news": "Новости",
|
"news": "Новости",
|
||||||
"save_advanced_settings": "Сохранить расширенные настройки"
|
"save_advanced_settings": "Сохранить расширенные настройки",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "fshirë",
|
"purged": "fshirë",
|
||||||
"this_post_was_purged": "Duke fshirë nga baza e të dhënave të tabelës — ky proces është i pakthyeshëm.",
|
"this_post_was_purged": "Duke fshirë nga baza e të dhënave të tabelës — ky proces është i pakthyeshëm.",
|
||||||
"news": "Lajme",
|
"news": "Lajme",
|
||||||
"save_advanced_settings": "Ruaj cilësimet e avancuara"
|
"save_advanced_settings": "Ruaj cilësimet e avancuara",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "rensad",
|
"purged": "rensad",
|
||||||
"this_post_was_purged": "Rensar från board-databasen — denna process är oåterkallelig.",
|
"this_post_was_purged": "Rensar från board-databasen — denna process är oåterkallelig.",
|
||||||
"news": "Nyheter",
|
"news": "Nyheter",
|
||||||
"save_advanced_settings": "Spara avancerade inställningar"
|
"save_advanced_settings": "Spara avancerade inställningar",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "తొలగించబడింది",
|
"purged": "తొలగించబడింది",
|
||||||
"this_post_was_purged": "బోర్డ్ డేటాబేస్ నుండి తొలగిస్తోంది — ఈ ప్రక్రియ తిరిగి వెనక్కి తీసుకోలేనిది.",
|
"this_post_was_purged": "బోర్డ్ డేటాబేస్ నుండి తొలగిస్తోంది — ఈ ప్రక్రియ తిరిగి వెనక్కి తీసుకోలేనిది.",
|
||||||
"news": "వార్తలు",
|
"news": "వార్తలు",
|
||||||
"save_advanced_settings": "అధునాతన సెట్టింగ్లను భద్రపరచండి"
|
"save_advanced_settings": "అధునాతన సెట్టింగ్లను భద్రపరచండి",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "ถูกลบ",
|
"purged": "ถูกลบ",
|
||||||
"this_post_was_purged": "กำลังลบออกจากฐานข้อมูลบอร์ด — ขั้นตอนนี้ไม่สามารถย้อนกลับได้",
|
"this_post_was_purged": "กำลังลบออกจากฐานข้อมูลบอร์ด — ขั้นตอนนี้ไม่สามารถย้อนกลับได้",
|
||||||
"news": "ข่าว",
|
"news": "ข่าว",
|
||||||
"save_advanced_settings": "บันทึกการตั้งค่าขั้นสูง"
|
"save_advanced_settings": "บันทึกการตั้งค่าขั้นสูง",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "silindi",
|
"purged": "silindi",
|
||||||
"this_post_was_purged": "Pano veritabanından siliniyor — bu işlem geri alınamaz.",
|
"this_post_was_purged": "Pano veritabanından siliniyor — bu işlem geri alınamaz.",
|
||||||
"news": "Haberler",
|
"news": "Haberler",
|
||||||
"save_advanced_settings": "Gelişmiş ayarları kaydet"
|
"save_advanced_settings": "Gelişmiş ayarları kaydet",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "видалено",
|
"purged": "видалено",
|
||||||
"this_post_was_purged": "Видалення з бази даних дошки — цей процес незворотний.",
|
"this_post_was_purged": "Видалення з бази даних дошки — цей процес незворотний.",
|
||||||
"news": "Новини",
|
"news": "Новини",
|
||||||
"save_advanced_settings": "Зберегти розширені налаштування"
|
"save_advanced_settings": "Зберегти розширені налаштування",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "ہٹا دیا گیا",
|
"purged": "ہٹا دیا گیا",
|
||||||
"this_post_was_purged": "بورڈ ڈیٹا بیس سے حذف کیا جا رہا ہے — یہ عمل ناقابل واپسی ہے۔",
|
"this_post_was_purged": "بورڈ ڈیٹا بیس سے حذف کیا جا رہا ہے — یہ عمل ناقابل واپسی ہے۔",
|
||||||
"news": "خبریں",
|
"news": "خبریں",
|
||||||
"save_advanced_settings": "اعلیٰ ترتیبات محفوظ کریں"
|
"save_advanced_settings": "اعلیٰ ترتیبات محفوظ کریں",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "đã xóa",
|
"purged": "đã xóa",
|
||||||
"this_post_was_purged": "Đang xóa khỏi cơ sở dữ liệu board — quá trình này không thể hoàn tác.",
|
"this_post_was_purged": "Đang xóa khỏi cơ sở dữ liệu board — quá trình này không thể hoàn tác.",
|
||||||
"news": "Tin tức",
|
"news": "Tin tức",
|
||||||
"save_advanced_settings": "Lưu cài đặt nâng cao"
|
"save_advanced_settings": "Lưu cài đặt nâng cao",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,5 +300,12 @@
|
|||||||
"purged": "已清除",
|
"purged": "已清除",
|
||||||
"this_post_was_purged": "正在从版面数据库中清除 — 此操作不可逆。",
|
"this_post_was_purged": "正在从版面数据库中清除 — 此操作不可逆。",
|
||||||
"news": "新闻",
|
"news": "新闻",
|
||||||
"save_advanced_settings": "保存高级设置"
|
"save_advanced_settings": "保存高级设置",
|
||||||
|
"external_quote_search_board_feed": "Searching in /{{board}}/",
|
||||||
|
"external_quote_search_thread": "Searching thread {{current}}/{{total}} in /{{board}}/",
|
||||||
|
"external_quote_redirecting_to_post": "Redirecting to post in /{{board}}/",
|
||||||
|
"external_quote_resolution_unavailable": "External quote resolution is unavailable right now",
|
||||||
|
"external_quote_cannot_resolve": "Could not find {{quote}} in /{{board}}/",
|
||||||
|
"external_quote_unavailable": "The quoted post is unavailable",
|
||||||
|
"external_quote_publish_missing": "Cannot publish - could not find the quoted post for {{quote}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import FeedCacheContainer from './components/feed-cache-container';
|
|||||||
import PostForm from './components/post-form';
|
import PostForm from './components/post-form';
|
||||||
import BoardBlotter from './components/board-blotter';
|
import BoardBlotter from './components/board-blotter';
|
||||||
import BoardsBar from './components/boards-bar';
|
import BoardsBar from './components/boards-bar';
|
||||||
|
import ExternalQuoteStatus from './components/external-quote-status/external-quote-status';
|
||||||
|
|
||||||
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
|
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
|
||||||
const BoardsBarEditModal = lazy(() => import('./components/boards-bar-edit-modal'));
|
const BoardsBarEditModal = lazy(() => import('./components/boards-bar-edit-modal'));
|
||||||
@@ -177,6 +178,7 @@ const GlobalLayout = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ExternalQuoteStatus />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ChallengeModal />
|
<ChallengeModal />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.container {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 18px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1100;
|
||||||
|
max-width: min(90vw, 420px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--quote-preview-background);
|
||||||
|
color: var(--post-link-text-color);
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #a40000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
top: 12px;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import useExternalQuoteStatusStore from '../../stores/use-external-quote-status-store';
|
||||||
|
import styles from './external-quote-status.module.css';
|
||||||
|
|
||||||
|
const ExternalQuoteStatus = () => {
|
||||||
|
const message = useExternalQuoteStatusStore((state) => state.message);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${styles.error}`} role='alert'>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExternalQuoteStatus;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import ExternalNumberQuoteLink from '../external-number-quote-link';
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
const testState = vi.hoisted(() => ({
|
||||||
|
resolveExternalQuoteTargetMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => {
|
||||||
|
if (options?.quote && options?.board) {
|
||||||
|
return `${key}:${options.quote}:${options.board}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||||
|
useAccount: () => ({ id: 'account-1' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@floating-ui/react', () => ({
|
||||||
|
autoUpdate: () => undefined,
|
||||||
|
offset: () => ({}),
|
||||||
|
shift: () => ({}),
|
||||||
|
size: () => ({}),
|
||||||
|
useFloating: () => ({
|
||||||
|
floatingStyles: {},
|
||||||
|
refs: {
|
||||||
|
setFloating: () => undefined,
|
||||||
|
setReference: () => undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
|
findDirectoryByAddress: () => undefined,
|
||||||
|
useDirectories: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||||
|
default: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/utils/external-quote-resolver', () => ({
|
||||||
|
resolveExternalQuoteTarget: (...args: unknown[]) => testState.resolveExternalQuoteTargetMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-external-quote-status-store', () => ({
|
||||||
|
default: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||||
|
selector({
|
||||||
|
clearStatus: vi.fn(),
|
||||||
|
setErrorStatus: vi.fn(),
|
||||||
|
setLoadingStatus: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../loading-ellipsis', () => ({
|
||||||
|
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../views/post', () => ({
|
||||||
|
Post: ({ post }: { post?: { cid?: string } }) => createElement('div', { 'data-testid': 'post-preview' }, post?.cid || 'missing-post'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
describe('ExternalNumberQuoteLink', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses hash-router hrefs for external numeric quotes', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{},
|
||||||
|
createElement(ExternalNumberQuoteLink, {
|
||||||
|
reference: {
|
||||||
|
boardIdentifier: 'fit',
|
||||||
|
kind: 'cross-board',
|
||||||
|
number: 77,
|
||||||
|
raw: '>>>/fit/77',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector('a');
|
||||||
|
expect(link?.getAttribute('href')).toBe('#/fit');
|
||||||
|
expect(link?.textContent).toBe('>>>/fit/77');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the resolved post in the hover preview after lazy resolution', async () => {
|
||||||
|
testState.resolveExternalQuoteTargetMock.mockResolvedValue({
|
||||||
|
boardPath: 'fit',
|
||||||
|
cid: 'cid-77',
|
||||||
|
comment: { cid: 'cid-77' },
|
||||||
|
isUnavailable: false,
|
||||||
|
route: '/fit/thread/cid-77',
|
||||||
|
subplebbitAddress: 'fit',
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{},
|
||||||
|
createElement(ExternalNumberQuoteLink, {
|
||||||
|
reference: {
|
||||||
|
boardIdentifier: 'fit',
|
||||||
|
kind: 'cross-board',
|
||||||
|
number: 77,
|
||||||
|
raw: '>>>/fit/77',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
container.querySelector('a')?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="post-preview"]')?.textContent).toBe('cid-77');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,6 +128,10 @@ vi.mock('../../reply-quote-preview', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../external-number-quote-link', () => ({
|
||||||
|
default: ({ reference }: { reference: { raw: string } }) => createElement('a', { 'data-testid': 'external-number-quote-link', href: '#' }, reference.raw),
|
||||||
|
}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
@@ -204,6 +208,16 @@ describe('Markdown', () => {
|
|||||||
expect(quotePreview?.textContent).toBe('comment-42');
|
expect(quotePreview?.textContent).toBe('comment-42');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders lazy same-board and cross-board number quotes when the cid is not cached', async () => {
|
||||||
|
await renderMarkdown({
|
||||||
|
content: '>>42 >>>/fit/77',
|
||||||
|
subplebbitAddress: 'music-posting.eth',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lazyLinks = Array.from(container.querySelectorAll('[data-testid="external-number-quote-link"]'));
|
||||||
|
expect(lazyLinks.map((node) => node.textContent)).toEqual(['>>42', '>>>/fit/77']);
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles inline media embeds for embeddable links outside catalog view', async () => {
|
it('toggles inline media embeds for embeddable links outside catalog view', async () => {
|
||||||
testState.mediaInfoByHref = {
|
testState.mediaInfoByHref = {
|
||||||
'https://cdn.example/image.png': {
|
'https://cdn.example/image.png': {
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAccount } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
|
import { useDirectories } from '../../hooks/use-directories';
|
||||||
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
|
import { resolveExternalQuoteTarget } from '../../lib/utils/external-quote-resolver';
|
||||||
|
import { ExternalQuoteReference, getExternalQuoteBoardLabel, getExternalQuoteStatusMessage } from '../../lib/utils/external-quote-utils';
|
||||||
|
import useExternalQuoteStatusStore from '../../stores/use-external-quote-status-store';
|
||||||
|
import LoadingEllipsis from '../loading-ellipsis';
|
||||||
|
import postStyles from '../../views/post/post.module.css';
|
||||||
|
import { Post } from '../../views/post';
|
||||||
|
import styles from './markdown.module.css';
|
||||||
|
|
||||||
|
interface ExternalNumberQuoteLinkProps {
|
||||||
|
reference: ExternalQuoteReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedExternalQuoteTarget = NonNullable<Awaited<ReturnType<typeof resolveExternalQuoteTarget>>>;
|
||||||
|
|
||||||
|
type PreviewState =
|
||||||
|
| {
|
||||||
|
kind: 'idle';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'loading';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'resolved';
|
||||||
|
target: ResolvedExternalQuoteTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewPosition = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const account = useAccount();
|
||||||
|
const directories = useDirectories();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setErrorStatus = useExternalQuoteStatusStore((state) => state.setErrorStatus);
|
||||||
|
const [isResolving, setIsResolving] = useState(false);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [previewState, setPreviewState] = useState<PreviewState>({ kind: 'idle' });
|
||||||
|
const [previewPosition, setPreviewPosition] = useState<PreviewPosition | null>(null);
|
||||||
|
const anchorRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const resolvedTargetRef = useRef<ResolvedExternalQuoteTarget | null | undefined>(undefined);
|
||||||
|
const resolutionPromiseRef = useRef<Promise<ResolvedExternalQuoteTarget | null> | null>(null);
|
||||||
|
const previewOpenRef = useRef(false);
|
||||||
|
const latestStatusMessageRef = useRef('');
|
||||||
|
|
||||||
|
const boardLabel = getExternalQuoteBoardLabel(reference, directories);
|
||||||
|
|
||||||
|
const updatePreviewPosition = (anchor: HTMLElement | null) => {
|
||||||
|
if (!anchor || isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 10;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const previewWidth = previewRef.current?.offsetWidth ?? Math.min(360, window.innerWidth - padding * 2);
|
||||||
|
const previewHeight = previewRef.current?.offsetHeight ?? 0;
|
||||||
|
const shouldPlaceLeft = rect.right + previewWidth + padding > window.innerWidth && rect.left - previewWidth - padding >= padding;
|
||||||
|
const left = shouldPlaceLeft ? Math.max(padding, rect.left - previewWidth - 8) : Math.min(window.innerWidth - previewWidth - padding, rect.right + 8);
|
||||||
|
const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
|
||||||
|
|
||||||
|
setPreviewPosition({ left, top });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPreviewOpen || isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reposition = () => updatePreviewPosition(anchorRef.current);
|
||||||
|
reposition();
|
||||||
|
|
||||||
|
window.addEventListener('resize', reposition);
|
||||||
|
window.addEventListener('scroll', reposition, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', reposition);
|
||||||
|
window.removeEventListener('scroll', reposition, true);
|
||||||
|
};
|
||||||
|
}, [isMobile, isPreviewOpen, previewState.kind]);
|
||||||
|
|
||||||
|
const getInitialSearchMessage = () =>
|
||||||
|
getExternalQuoteStatusMessage(t, {
|
||||||
|
boardLabel,
|
||||||
|
phase: 'search-board',
|
||||||
|
quoteDisplay: reference.raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUnavailableMessage = () => t('external_quote_unavailable');
|
||||||
|
const getCannotResolveMessage = () =>
|
||||||
|
t('external_quote_cannot_resolve', {
|
||||||
|
board: boardLabel,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
quote: reference.raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveTarget = async ({ updatePreview }: { updatePreview: boolean }) => {
|
||||||
|
if (!account?.id) {
|
||||||
|
throw new Error('Missing active account while resolving external quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedTargetRef.current !== undefined) {
|
||||||
|
return resolvedTargetRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolutionPromiseRef.current) {
|
||||||
|
resolutionPromiseRef.current = resolveExternalQuoteTarget({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
onStatus: (status) => {
|
||||||
|
const message = getExternalQuoteStatusMessage(t, status);
|
||||||
|
latestStatusMessageRef.current = message;
|
||||||
|
|
||||||
|
if (updatePreview && previewOpenRef.current) {
|
||||||
|
setPreviewState({ kind: 'loading', message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reference,
|
||||||
|
}).then((target) => {
|
||||||
|
resolvedTargetRef.current = target;
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = await resolutionPromiseRef.current;
|
||||||
|
resolutionPromiseRef.current = null;
|
||||||
|
return target;
|
||||||
|
} catch (error) {
|
||||||
|
resolutionPromiseRef.current = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
anchorRef.current = e.currentTarget;
|
||||||
|
previewOpenRef.current = true;
|
||||||
|
setIsPreviewOpen(true);
|
||||||
|
updatePreviewPosition(e.currentTarget);
|
||||||
|
|
||||||
|
if (resolvedTargetRef.current === null) {
|
||||||
|
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedTargetRef.current?.comment) {
|
||||||
|
setPreviewState({ kind: 'resolved', target: resolvedTargetRef.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewState({ kind: 'loading', message: latestStatusMessageRef.current || getInitialSearchMessage() });
|
||||||
|
|
||||||
|
let target: ResolvedExternalQuoteTarget | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
target = await resolveTarget({ updatePreview: true });
|
||||||
|
} catch {
|
||||||
|
if (previewOpenRef.current) {
|
||||||
|
setPreviewState({ kind: 'error', message: t('external_quote_resolution_unavailable') });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewOpenRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isUnavailable || !target.comment) {
|
||||||
|
setPreviewState({ kind: 'error', message: getUnavailableMessage() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewState({ kind: 'resolved', target });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
previewOpenRef.current = false;
|
||||||
|
setIsPreviewOpen(false);
|
||||||
|
setPreviewPosition(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isResolving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account?.id) {
|
||||||
|
setErrorStatus(t('external_quote_resolution_unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResolving(true);
|
||||||
|
|
||||||
|
const finishWithError = (message: string) => {
|
||||||
|
setErrorStatus(message);
|
||||||
|
setIsResolving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let target: ResolvedExternalQuoteTarget | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
target = await resolveTarget({ updatePreview: false });
|
||||||
|
} catch {
|
||||||
|
finishWithError(getCannotResolveMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
finishWithError(getCannotResolveMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isUnavailable) {
|
||||||
|
finishWithError(getUnavailableMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResolving(false);
|
||||||
|
navigate(target.route);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewContent =
|
||||||
|
previewState.kind === 'idle' ? null : previewState.kind === 'resolved' ? (
|
||||||
|
previewState.target.comment ? (
|
||||||
|
<Post post={previewState.target.comment} showReplies={false} />
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{getUnavailableMessage()}</div>
|
||||||
|
)
|
||||||
|
) : previewState.kind === 'loading' ? (
|
||||||
|
<div className={styles.externalQuotePreviewState}>
|
||||||
|
<LoadingEllipsis string={previewState.message} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{previewState.message}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
aria-busy={isResolving || undefined}
|
||||||
|
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
|
||||||
|
href={`#/${boardLabel}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
ref={anchorRef}
|
||||||
|
>
|
||||||
|
{reference.raw}
|
||||||
|
</a>
|
||||||
|
{!isMobile &&
|
||||||
|
isPreviewOpen &&
|
||||||
|
previewPosition &&
|
||||||
|
previewContent &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={postStyles.replyQuotePreview}
|
||||||
|
data-thread-scroll-preview='true'
|
||||||
|
ref={previewRef}
|
||||||
|
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{previewContent}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExternalNumberQuoteLink;
|
||||||
@@ -18,6 +18,21 @@
|
|||||||
text-decoration: var(--post-quotelink-text-decoration-hover);
|
text-decoration: var(--post-quotelink-text-decoration-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inlineQuoteLinkResolving {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.75;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.externalQuotePreviewState {
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.externalQuotePreviewError {
|
||||||
|
color: #a40000;
|
||||||
|
}
|
||||||
|
|
||||||
.embedButton {
|
.embedButton {
|
||||||
color: var(--button-desktop-text-color);
|
color: var(--button-desktop-text-color);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import styles from './markdown.module.css';
|
|||||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||||
import { canEmbed } from '../embed';
|
import { canEmbed } from '../embed';
|
||||||
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
|
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
|
||||||
|
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
|
||||||
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
||||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||||
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
||||||
import { useComment } from '@bitsocialnet/bitsocial-react-hooks';
|
import { useComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
import ReplyQuotePreview from '../reply-quote-preview';
|
||||||
|
import ExternalNumberQuoteLink from './external-number-quote-link';
|
||||||
|
|
||||||
const safeParseUrl = (href: string): URL | null => {
|
const safeParseUrl = (href: string): URL | null => {
|
||||||
try {
|
try {
|
||||||
@@ -142,6 +144,7 @@ type Token =
|
|||||||
| { type: 'text'; value: string }
|
| { type: 'text'; value: string }
|
||||||
| { type: 'url'; href: string }
|
| { type: 'url'; href: string }
|
||||||
| { type: 'quoteLink'; number: number }
|
| { type: 'quoteLink'; number: number }
|
||||||
|
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||||
| { type: 'crossBoardLink'; display: string; route: string }
|
| { type: 'crossBoardLink'; display: string; route: string }
|
||||||
| { type: 'spoiler'; tokens: Token[] };
|
| { type: 'spoiler'; tokens: Token[] };
|
||||||
|
|
||||||
@@ -150,7 +153,10 @@ const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA
|
|||||||
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||||
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
||||||
|
|
||||||
const COMBINED_REGEX = new RegExp(`(${SPOILER_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g');
|
const COMBINED_REGEX = new RegExp(
|
||||||
|
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
function getCrossboardRoute(fullPattern: string): string | null {
|
function getCrossboardRoute(fullPattern: string): string | null {
|
||||||
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
||||||
@@ -190,7 +196,23 @@ function tokenize(text: string): Token[] {
|
|||||||
const innerContent = match[2];
|
const innerContent = match[2];
|
||||||
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
||||||
} else if (match[3] !== undefined) {
|
} else if (match[3] !== undefined) {
|
||||||
const pathPart = match[4];
|
const boardIdentifier = match[4];
|
||||||
|
const number = parseInt(match[5], 10);
|
||||||
|
if (boardIdentifier && !Number.isNaN(number)) {
|
||||||
|
tokens.push({
|
||||||
|
type: 'crossBoardNumberQuoteLink',
|
||||||
|
reference: {
|
||||||
|
boardIdentifier,
|
||||||
|
kind: 'cross-board',
|
||||||
|
number,
|
||||||
|
raw: `>>>/${boardIdentifier}/${number}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tokens.push({ type: 'text', value: fullMatch });
|
||||||
|
}
|
||||||
|
} else if (match[6] !== undefined) {
|
||||||
|
const pathPart = match[7];
|
||||||
const fullPattern = `>>>/${pathPart}`;
|
const fullPattern = `>>>/${pathPart}`;
|
||||||
const route = getCrossboardRoute(fullPattern);
|
const route = getCrossboardRoute(fullPattern);
|
||||||
if (route) {
|
if (route) {
|
||||||
@@ -198,10 +220,10 @@ function tokenize(text: string): Token[] {
|
|||||||
} else {
|
} else {
|
||||||
tokens.push({ type: 'text', value: fullMatch });
|
tokens.push({ type: 'text', value: fullMatch });
|
||||||
}
|
}
|
||||||
} else if (match[5] !== undefined) {
|
} else if (match[8] !== undefined) {
|
||||||
const number = parseInt(match[6], 10);
|
const number = parseInt(match[9], 10);
|
||||||
tokens.push({ type: 'quoteLink', number });
|
tokens.push({ type: 'quoteLink', number });
|
||||||
} else if (match[7] !== undefined) {
|
} else if (match[10] !== undefined) {
|
||||||
tokens.push({ type: 'url', href: fullMatch });
|
tokens.push({ type: 'url', href: fullMatch });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +269,12 @@ function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[
|
|||||||
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
|
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
case 'crossBoardNumberQuoteLink':
|
||||||
|
return (
|
||||||
|
<span key={i} className={styles.inlineQuoteLink}>
|
||||||
|
<ExternalNumberQuoteLink reference={token.reference} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
case 'crossBoardLink':
|
case 'crossBoardLink':
|
||||||
return (
|
return (
|
||||||
<Link key={i} to={token.route}>
|
<Link key={i} to={token.route}>
|
||||||
@@ -283,6 +311,19 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cid && subplebbitAddress) {
|
||||||
|
return (
|
||||||
|
<ExternalNumberQuoteLink
|
||||||
|
reference={{
|
||||||
|
kind: 'same-board',
|
||||||
|
number,
|
||||||
|
raw: `>>${number}`,
|
||||||
|
subplebbitAddress,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ const testState = vi.hoisted(() => ({
|
|||||||
handleUploadMock: vi.fn(),
|
handleUploadMock: vi.fn(),
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
isOnlineStatusLoading: false,
|
isOnlineStatusLoading: false,
|
||||||
|
isResolvingExternalQuotes: false,
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
offlineTitle: 'offline board',
|
offlineTitle: 'offline board',
|
||||||
postIndex: undefined as number | undefined,
|
postIndex: undefined as number | undefined,
|
||||||
publishPostMock: vi.fn(),
|
publishPostMock: vi.fn(),
|
||||||
publishReplyMock: vi.fn(),
|
publishReplyMock: vi.fn(),
|
||||||
|
publishReplyError: null as string | null,
|
||||||
|
publishReplyStateMessage: null as string | null,
|
||||||
replyIndex: undefined as number | undefined,
|
replyIndex: undefined as number | undefined,
|
||||||
resetPublishPostOptionsMock: vi.fn(),
|
resetPublishPostOptionsMock: vi.fn(),
|
||||||
resetPublishReplyOptionsMock: vi.fn(),
|
resetPublishReplyOptionsMock: vi.fn(),
|
||||||
@@ -140,7 +143,10 @@ vi.mock('../../../hooks/use-publish-reply', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||||
publishReply: testState.publishReplyMock,
|
publishReply: testState.publishReplyMock,
|
||||||
|
publishReplyError: testState.publishReplyError,
|
||||||
|
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||||
replyIndex: testState.replyIndex,
|
replyIndex: testState.replyIndex,
|
||||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||||
setPublishReplyOptions: (options: Record<string, unknown>) => {
|
setPublishReplyOptions: (options: Record<string, unknown>) => {
|
||||||
@@ -273,8 +279,11 @@ describe('PostForm', () => {
|
|||||||
testState.gifFrameStatus = 'idle';
|
testState.gifFrameStatus = 'idle';
|
||||||
testState.isOffline = false;
|
testState.isOffline = false;
|
||||||
testState.isOnlineStatusLoading = false;
|
testState.isOnlineStatusLoading = false;
|
||||||
|
testState.isResolvingExternalQuotes = false;
|
||||||
testState.offlineTitle = 'offline board';
|
testState.offlineTitle = 'offline board';
|
||||||
testState.postIndex = undefined;
|
testState.postIndex = undefined;
|
||||||
|
testState.publishReplyError = null;
|
||||||
|
testState.publishReplyStateMessage = null;
|
||||||
testState.replyIndex = undefined;
|
testState.replyIndex = undefined;
|
||||||
testState.resolvedSubplebbitAddress = undefined;
|
testState.resolvedSubplebbitAddress = undefined;
|
||||||
testState.showUploadControls = true;
|
testState.showUploadControls = true;
|
||||||
|
|||||||
@@ -187,3 +187,7 @@
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PostFormActions = ({
|
const PostFormActions = ({
|
||||||
|
disableReplyPublish = false,
|
||||||
variant,
|
variant,
|
||||||
t,
|
t,
|
||||||
isInPostView,
|
isInPostView,
|
||||||
@@ -47,6 +48,7 @@ const PostFormActions = ({
|
|||||||
isUploading,
|
isUploading,
|
||||||
showUploadControls,
|
showUploadControls,
|
||||||
}: {
|
}: {
|
||||||
|
disableReplyPublish?: boolean;
|
||||||
variant: 'reply' | 'post' | 'upload';
|
variant: 'reply' | 'post' | 'upload';
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
isInPostView: boolean;
|
isInPostView: boolean;
|
||||||
@@ -58,7 +60,7 @@ const PostFormActions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (variant === 'reply' && isInPostView) {
|
if (variant === 'reply' && isInPostView) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onPublishReply} disabled={isUploading}>
|
<button onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -106,6 +108,7 @@ interface PostFormFieldsProps {
|
|||||||
onPublishReply: () => void;
|
onPublishReply: () => void;
|
||||||
onPublishPost: () => void;
|
onPublishPost: () => void;
|
||||||
handleUpload: () => void;
|
handleUpload: () => void;
|
||||||
|
disableReplyPublish: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostFormFields = ({
|
const PostFormFields = ({
|
||||||
@@ -138,6 +141,7 @@ const PostFormFields = ({
|
|||||||
onPublishReply,
|
onPublishReply,
|
||||||
onPublishPost,
|
onPublishPost,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
disableReplyPublish,
|
||||||
}: PostFormFieldsProps) => (
|
}: PostFormFieldsProps) => (
|
||||||
<>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -164,6 +168,7 @@ const PostFormFields = ({
|
|||||||
onPublishReply={onPublishReply}
|
onPublishReply={onPublishReply}
|
||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
|
disableReplyPublish={disableReplyPublish}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -187,6 +192,7 @@ const PostFormFields = ({
|
|||||||
onPublishReply={onPublishReply}
|
onPublishReply={onPublishReply}
|
||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
|
disableReplyPublish={disableReplyPublish}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -229,6 +235,7 @@ const PostFormFields = ({
|
|||||||
onPublishReply={onPublishReply}
|
onPublishReply={onPublishReply}
|
||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
|
disableReplyPublish={disableReplyPublish}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -384,7 +391,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
// in post page, publish a reply to the post
|
// in post page, publish a reply to the post
|
||||||
const isInPostView = isPostPageView(location.pathname, params);
|
const isInPostView = isPostPageView(location.pathname, params);
|
||||||
const cid = params?.commentCid as string;
|
const cid = params?.commentCid as string;
|
||||||
const { setPublishReplyOptions, resetPublishReplyOptions, replyIndex, publishReply } = usePublishReply({ cid, subplebbitAddress });
|
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||||
|
usePublishReply({ cid, subplebbitAddress });
|
||||||
|
|
||||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const content = e.target.value;
|
const content = e.target.value;
|
||||||
@@ -452,41 +460,46 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}, [displayName, isInPostView, setPublishReplyOptions, setPublishPostOptions]);
|
}, [displayName, isInPostView, setPublishReplyOptions, setPublishPostOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={styles.postFormTable}>
|
<>
|
||||||
<tbody>
|
<table className={styles.postFormTable}>
|
||||||
<PostFormFields
|
<tbody>
|
||||||
t={t}
|
<PostFormFields
|
||||||
account={account}
|
t={t}
|
||||||
displayName={displayName}
|
account={account}
|
||||||
isInPostView={isInPostView}
|
displayName={displayName}
|
||||||
subjectRef={subjectRef}
|
isInPostView={isInPostView}
|
||||||
textRef={textRef}
|
subjectRef={subjectRef}
|
||||||
urlRef={urlRef}
|
textRef={textRef}
|
||||||
url={url}
|
urlRef={urlRef}
|
||||||
lengthError={lengthError}
|
url={url}
|
||||||
handleContentChange={handleContentChange}
|
lengthError={lengthError}
|
||||||
setPublishPostOptions={setPublishPostOptions}
|
handleContentChange={handleContentChange}
|
||||||
setPublishReplyOptions={setPublishReplyOptions}
|
setPublishPostOptions={setPublishPostOptions}
|
||||||
setUrl={setUrl}
|
setPublishReplyOptions={setPublishReplyOptions}
|
||||||
isUploading={isUploading}
|
setUrl={setUrl}
|
||||||
uploadedFileName={uploadedFileName}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
uploadedFileName={uploadedFileName}
|
||||||
showSpoilerForPost={showSpoilerForPost}
|
showUploadControls={showUploadControls}
|
||||||
showSpoilerForReply={showSpoilerForReply}
|
showSpoilerForPost={showSpoilerForPost}
|
||||||
isInAllView={isInAllView}
|
showSpoilerForReply={showSpoilerForReply}
|
||||||
isInSubscriptionsView={isInSubscriptionsView}
|
isInAllView={isInAllView}
|
||||||
isInModView={isInModView}
|
isInSubscriptionsView={isInSubscriptionsView}
|
||||||
directories={directories}
|
isInModView={isInModView}
|
||||||
accountSubplebbitAddresses={accountSubplebbitAddresses}
|
directories={directories}
|
||||||
subscriptions={subscriptions}
|
accountSubplebbitAddresses={accountSubplebbitAddresses}
|
||||||
subplebbitAddress={subplebbitAddress}
|
subscriptions={subscriptions}
|
||||||
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
subplebbitAddress={subplebbitAddress}
|
||||||
onPublishReply={onPublishReply}
|
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
||||||
onPublishPost={onPublishPost}
|
onPublishReply={onPublishReply}
|
||||||
handleUpload={handleUpload}
|
onPublishPost={onPublishPost}
|
||||||
/>
|
handleUpload={handleUpload}
|
||||||
</tbody>
|
disableReplyPublish={isResolvingExternalQuotes}
|
||||||
</table>
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{publishReplyError && <div className={styles.error}>{publishReplyError}</div>}
|
||||||
|
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||||
handleUploadMock: vi.fn(),
|
handleUploadMock: vi.fn(),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
isResolvingExternalQuotes: false,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
offlineTitle: '' as string | false,
|
offlineTitle: '' as string | false,
|
||||||
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
|
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
|
||||||
@@ -26,6 +27,8 @@ const testState = vi.hoisted(() => ({
|
|||||||
offlineWarningVisible: false,
|
offlineWarningVisible: false,
|
||||||
openEmpty: false,
|
openEmpty: false,
|
||||||
publishReplyMock: vi.fn(),
|
publishReplyMock: vi.fn(),
|
||||||
|
publishReplyError: null as string | null,
|
||||||
|
publishReplyStateMessage: null as string | null,
|
||||||
quoteInsertNumber: undefined as number | undefined,
|
quoteInsertNumber: undefined as number | undefined,
|
||||||
quoteInsertRequestId: 0,
|
quoteInsertRequestId: 0,
|
||||||
quoteInsertSelectedText: '',
|
quoteInsertSelectedText: '',
|
||||||
@@ -128,7 +131,10 @@ vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||||
publishReply: testState.publishReplyMock,
|
publishReply: testState.publishReplyMock,
|
||||||
|
publishReplyError: testState.publishReplyError,
|
||||||
|
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||||
replyIndex: testState.replyIndex,
|
replyIndex: testState.replyIndex,
|
||||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||||
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||||||
@@ -249,6 +255,7 @@ describe('ReplyModal', () => {
|
|||||||
};
|
};
|
||||||
testState.handleUploadMock.mockReset();
|
testState.handleUploadMock.mockReset();
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
|
testState.isResolvingExternalQuotes = false;
|
||||||
testState.isUploading = false;
|
testState.isUploading = false;
|
||||||
testState.offlineTitle = '';
|
testState.offlineTitle = '';
|
||||||
testState.offlineStates = {};
|
testState.offlineStates = {};
|
||||||
@@ -256,6 +263,8 @@ describe('ReplyModal', () => {
|
|||||||
testState.offlineWarningVisible = false;
|
testState.offlineWarningVisible = false;
|
||||||
testState.openEmpty = false;
|
testState.openEmpty = false;
|
||||||
testState.publishReplyMock.mockReset();
|
testState.publishReplyMock.mockReset();
|
||||||
|
testState.publishReplyError = null;
|
||||||
|
testState.publishReplyStateMessage = null;
|
||||||
testState.quoteInsertNumber = undefined;
|
testState.quoteInsertNumber = undefined;
|
||||||
testState.quoteInsertRequestId = 0;
|
testState.quoteInsertRequestId = 0;
|
||||||
testState.quoteInsertSelectedText = '';
|
testState.quoteInsertSelectedText = '';
|
||||||
|
|||||||
@@ -157,3 +157,13 @@
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
text-shadow: 0 1px rgba(0, 0, 0, 0.20);
|
text-shadow: 0 1px rgba(0, 0, 0, 0.20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
width: 294px;
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--post-link-text-color);
|
||||||
|
padding: 3px 5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({
|
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||||
cid: parentCid,
|
usePublishReply({
|
||||||
subplebbitAddress,
|
cid: parentCid,
|
||||||
postCid,
|
subplebbitAddress,
|
||||||
});
|
postCid,
|
||||||
|
});
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const { displayName } = account?.author || {};
|
const { displayName } = account?.author || {};
|
||||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@@ -362,11 +363,18 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
]
|
]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button className={styles.publishButton} onClick={onPublishReply}>
|
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} onClick={onPublishReply}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
|
{lengthError ? (
|
||||||
|
<div className={styles.error}>{lengthError}</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className={styles.error}>{error}</div>
|
||||||
|
) : (
|
||||||
|
publishReplyError && <div className={styles.error}>{publishReplyError}</div>
|
||||||
|
)}
|
||||||
|
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
||||||
<BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} subplebbitAddress={subplebbitAddress} />
|
<BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} subplebbitAddress={subplebbitAddress} />
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ import usePublishReplyStore from '../../stores/use-publish-reply-store';
|
|||||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
|
account: { id: 'account-1' } as Record<string, any>,
|
||||||
abandonPublishMock: vi.fn(async () => undefined),
|
abandonPublishMock: vi.fn(async () => undefined),
|
||||||
|
directories: [] as Array<Record<string, unknown>>,
|
||||||
index: 7,
|
index: 7,
|
||||||
lastPublishOptions: undefined as Record<string, any> | undefined,
|
lastPublishOptions: undefined as Record<string, any> | undefined,
|
||||||
publishCommentMock: vi.fn(),
|
publishCommentMock: vi.fn(),
|
||||||
|
resolveExternalQuoteTargetMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||||
|
useAccount: () => testState.account,
|
||||||
usePublishComment: (options: Record<string, any>) => {
|
usePublishComment: (options: Record<string, any>) => {
|
||||||
testState.lastPublishOptions = options;
|
testState.lastPublishOptions = options;
|
||||||
return {
|
return {
|
||||||
@@ -28,6 +38,14 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/use-directories', () => ({
|
||||||
|
useDirectories: () => testState.directories,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/utils/external-quote-resolver', () => ({
|
||||||
|
resolveExternalQuoteTarget: (...args: any[]) => testState.resolveExternalQuoteTargetMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let latestValue: ReturnType<typeof usePublishReply>;
|
let latestValue: ReturnType<typeof usePublishReply>;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
@@ -46,6 +64,8 @@ const renderHook = () => {
|
|||||||
describe('usePublishReply', () => {
|
describe('usePublishReply', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
testState.account = { id: 'account-1' };
|
||||||
|
testState.directories = [];
|
||||||
testState.index = 7;
|
testState.index = 7;
|
||||||
testState.lastPublishOptions = undefined;
|
testState.lastPublishOptions = undefined;
|
||||||
useChallengesStore.setState({ challenges: [] });
|
useChallengesStore.setState({ challenges: [] });
|
||||||
@@ -81,7 +101,7 @@ describe('usePublishReply', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(latestValue.replyIndex).toBe(7);
|
expect(latestValue.replyIndex).toBe(7);
|
||||||
expect(latestValue.publishReply).toBe(testState.publishCommentMock);
|
expect(typeof latestValue.publishReply).toBe('function');
|
||||||
expect(testState.lastPublishOptions).toMatchObject({
|
expect(testState.lastPublishOptions).toMatchObject({
|
||||||
author: { displayName: 'Bob' },
|
author: { displayName: 'Bob' },
|
||||||
content: 'Replying to >>12',
|
content: 'Replying to >>12',
|
||||||
@@ -94,6 +114,66 @@ describe('usePublishReply', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves same-board external quote references before triggering publish', async () => {
|
||||||
|
testState.resolveExternalQuoteTargetMock.mockResolvedValue({
|
||||||
|
cid: 'external-cid',
|
||||||
|
route: '/music/thread/external-cid',
|
||||||
|
subplebbitAddress: 'music.eth',
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestValue.setPublishReplyOptions({
|
||||||
|
content: 'Replying to >>44',
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await latestValue.publishReply();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.resolveExternalQuoteTargetMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testState.lastPublishOptions?.quotedCids).toEqual(['external-cid']);
|
||||||
|
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve cross-board numeric quotes before publish', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
latestValue.setPublishReplyOptions({
|
||||||
|
content: 'Replying to >>>/fit/44',
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await latestValue.publishReply();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.resolveExternalQuoteTargetMock).not.toHaveBeenCalled();
|
||||||
|
expect(testState.lastPublishOptions?.quotedCids).toBeUndefined();
|
||||||
|
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks publish when a same-board external quote cannot be resolved', async () => {
|
||||||
|
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestValue.setPublishReplyOptions({
|
||||||
|
content: 'Replying to >>44',
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await latestValue.publishReply();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestValue.publishReplyError).toContain('external_quote_publish_missing');
|
||||||
|
expect(testState.publishCommentMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('queues reply challenges and clears the scoped reply store on reset', async () => {
|
it('queues reply challenges and clears the scoped reply store on reset', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
latestValue.setPublishReplyOptions({
|
latestValue.setPublishReplyOptions({
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useCallback, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Comment, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Comment, useAccount, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
|
import { useDirectories } from './use-directories';
|
||||||
import usePublishReplyStore from '../stores/use-publish-reply-store';
|
import usePublishReplyStore from '../stores/use-publish-reply-store';
|
||||||
import usePostNumberStore from '../stores/use-post-number-store';
|
import usePostNumberStore from '../stores/use-post-number-store';
|
||||||
import { getQuotedCidsFromContent, mergeQuotedCids } from '../lib/utils/reply-quote-utils';
|
import { getQuotedCidsFromContent, mergeQuotedCids } from '../lib/utils/reply-quote-utils';
|
||||||
|
import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage } from '../lib/utils/external-quote-utils';
|
||||||
|
import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver';
|
||||||
import useChallengesStore from '../stores/use-challenges-store';
|
import useChallengesStore from '../stores/use-challenges-store';
|
||||||
|
|
||||||
const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; subplebbitAddress: string; postCid?: string }) => {
|
const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; subplebbitAddress: string; postCid?: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const parentCid = cid;
|
const parentCid = cid;
|
||||||
|
const account = useAccount();
|
||||||
|
const directories = useDirectories();
|
||||||
|
|
||||||
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore((state) => ({
|
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore((state) => ({
|
||||||
author: state.author[parentCid],
|
author: state.author[parentCid],
|
||||||
@@ -20,6 +27,12 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
|
|||||||
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
|
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
|
||||||
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
const addChallenge = useChallengesStore((state) => state.addChallenge);
|
||||||
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
|
||||||
|
const startedPublishRequestIdRef = useRef(0);
|
||||||
|
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
|
||||||
|
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
|
||||||
|
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
|
||||||
|
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
|
||||||
|
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
|
||||||
const abandonCurrentPublish = useCallback(async () => {
|
const abandonCurrentPublish = useCallback(async () => {
|
||||||
await abandonPublishRef.current?.();
|
await abandonPublishRef.current?.();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -63,8 +76,35 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
|
|||||||
|
|
||||||
const scopedNumberToCid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress] : undefined));
|
const scopedNumberToCid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress] : undefined));
|
||||||
const quotedCids = useMemo(() => getQuotedCidsFromContent(content, scopedNumberToCid), [content, scopedNumberToCid]);
|
const quotedCids = useMemo(() => getQuotedCidsFromContent(content, scopedNumberToCid), [content, scopedNumberToCid]);
|
||||||
|
const unresolvedExternalQuoteReferences = useMemo(
|
||||||
|
() =>
|
||||||
|
extractUnresolvedExternalQuoteReferences({
|
||||||
|
content,
|
||||||
|
scopedNumberToCid,
|
||||||
|
subplebbitAddress,
|
||||||
|
}),
|
||||||
|
[content, scopedNumberToCid, subplebbitAddress],
|
||||||
|
);
|
||||||
|
const publishResolvableQuoteReferences = useMemo(
|
||||||
|
() => unresolvedExternalQuoteReferences.filter((reference) => reference.kind === 'same-board'),
|
||||||
|
[unresolvedExternalQuoteReferences],
|
||||||
|
);
|
||||||
|
|
||||||
const mergedPublishOptions = useMemo(() => mergeQuotedCids(publishCommentOptions, quotedCids), [publishCommentOptions, quotedCids]);
|
const mergedQuotedCids = useMemo(() => {
|
||||||
|
const merged = new Set<string>();
|
||||||
|
|
||||||
|
for (const cid of quotedCids ?? []) {
|
||||||
|
merged.add(cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cid of resolvedExternalQuotedCids ?? []) {
|
||||||
|
merged.add(cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.size > 0 ? [...merged] : undefined;
|
||||||
|
}, [quotedCids, resolvedExternalQuotedCids]);
|
||||||
|
|
||||||
|
const mergedPublishOptions = useMemo(() => mergeQuotedCids(publishCommentOptions, mergedQuotedCids), [publishCommentOptions, mergedQuotedCids]);
|
||||||
const publishOptionsWithAbandon = useMemo(
|
const publishOptionsWithAbandon = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...mergedPublishOptions,
|
...mergedPublishOptions,
|
||||||
@@ -78,11 +118,83 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
|
|||||||
const { index, publishComment, abandonPublish } = usePublishComment(publishOptionsWithAbandon);
|
const { index, publishComment, abandonPublish } = usePublishComment(publishOptionsWithAbandon);
|
||||||
abandonPublishRef.current = abandonPublish;
|
abandonPublishRef.current = abandonPublish;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResolvedExternalQuotedCids(undefined);
|
||||||
|
setPublishReplyError(null);
|
||||||
|
setPublishReplyStateMessage(null);
|
||||||
|
setIsResolvingExternalQuotes(false);
|
||||||
|
}, [content, subplebbitAddress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedPublishRequestIdRef.current = pendingPublishRequestId;
|
||||||
|
publishComment();
|
||||||
|
}, [pendingPublishRequestId, publishComment]);
|
||||||
|
|
||||||
|
const publishReply = useCallback(async () => {
|
||||||
|
setPublishReplyError(null);
|
||||||
|
|
||||||
|
if (publishResolvableQuoteReferences.length === 0) {
|
||||||
|
setResolvedExternalQuotedCids(undefined);
|
||||||
|
setPublishReplyStateMessage(null);
|
||||||
|
setPendingPublishRequestId((requestId) => requestId + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account?.id) {
|
||||||
|
setPublishReplyError(t('external_quote_resolution_unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResolvingExternalQuotes(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolvedCids = new Set<string>();
|
||||||
|
|
||||||
|
for (const reference of publishResolvableQuoteReferences) {
|
||||||
|
const resolvedTarget = await resolveExternalQuoteTarget({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
onStatus: (status) => {
|
||||||
|
setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status));
|
||||||
|
},
|
||||||
|
reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolvedTarget?.cid) {
|
||||||
|
setPublishReplyError(
|
||||||
|
t('external_quote_publish_missing', {
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
quote: reference.raw,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedCids.add(resolvedTarget.cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined);
|
||||||
|
setPublishReplyStateMessage(null);
|
||||||
|
setPendingPublishRequestId((requestId) => requestId + 1);
|
||||||
|
} catch {
|
||||||
|
setPublishReplyError(t('external_quote_resolution_unavailable'));
|
||||||
|
} finally {
|
||||||
|
setIsResolvingExternalQuotes(false);
|
||||||
|
}
|
||||||
|
}, [account, directories, publishResolvableQuoteReferences, t]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isResolvingExternalQuotes,
|
||||||
|
publishReply,
|
||||||
|
publishReplyError,
|
||||||
|
publishReplyStateMessage,
|
||||||
setPublishReplyOptions,
|
setPublishReplyOptions,
|
||||||
resetPublishReplyOptions,
|
resetPublishReplyOptions,
|
||||||
replyIndex: index,
|
replyIndex: index,
|
||||||
publishReply: publishComment,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ describe('url-utils', () => {
|
|||||||
|
|
||||||
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
|
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
|
||||||
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
|
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
|
||||||
|
expect(isValidCrossboardPattern('>>>/biz/123')).toBe(true);
|
||||||
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
|
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
|
||||||
|
expect(isValidCrossboardPattern('>>>/board.eth/123')).toBe(true);
|
||||||
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
|
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
|
||||||
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false);
|
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false);
|
||||||
expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
|
expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
|
import feedsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds';
|
||||||
|
import repliesStore, { feedOptionsToFeedName } from '@bitsocialnet/bitsocial-react-hooks/dist/stores/replies';
|
||||||
|
import subplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
||||||
|
import type { DirectoryCommunity } from '../../hooks/use-directories';
|
||||||
|
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||||
|
import type { ExternalQuoteReference, ExternalQuoteSearchStatus } from './external-quote-utils';
|
||||||
|
import { getExternalQuoteBoardAddress, getExternalQuoteBoardLabel } from './external-quote-utils';
|
||||||
|
import { getBoardPath } from './route-utils';
|
||||||
|
|
||||||
|
const BOARD_FEED_SORT_TYPE = 'new';
|
||||||
|
const BOARD_SEARCH_POSTS_PER_PAGE = 25;
|
||||||
|
const THREAD_REPLIES_SORT_TYPE = 'best';
|
||||||
|
const THREAD_SEARCH_REPLIES_PER_PAGE = 25;
|
||||||
|
const WAIT_FOR_STORE_TIMEOUT_MS = 30000;
|
||||||
|
const WAIT_FOR_STORE_INTERVAL_MS = 100;
|
||||||
|
|
||||||
|
type ResolverAccount = {
|
||||||
|
id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedExternalQuoteTarget = {
|
||||||
|
boardPath: string;
|
||||||
|
cid: string;
|
||||||
|
comment?: Comment;
|
||||||
|
isUnavailable: boolean;
|
||||||
|
route: string;
|
||||||
|
subplebbitAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitFor = async <T>(callback: () => T | undefined | false, timeoutMs = WAIT_FOR_STORE_TIMEOUT_MS) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startedAt <= timeoutMs) {
|
||||||
|
const result = callback();
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, WAIT_FOR_STORE_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out while resolving external quote');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnavailableComment = (
|
||||||
|
comment?: {
|
||||||
|
commentModeration?: {
|
||||||
|
purged?: boolean;
|
||||||
|
};
|
||||||
|
deleted?: boolean;
|
||||||
|
removed?: boolean;
|
||||||
|
} | null,
|
||||||
|
) => Boolean(comment?.deleted || comment?.removed || comment?.commentModeration?.purged);
|
||||||
|
|
||||||
|
const getBoardFeedName = (accountId: string, subplebbitAddress: string) =>
|
||||||
|
`external-quote-board-${accountId}-${subplebbitAddress}-${BOARD_FEED_SORT_TYPE}-${BOARD_SEARCH_POSTS_PER_PAGE}`;
|
||||||
|
|
||||||
|
const getCachedComment = (cid?: string) => (cid ? subplebbitsPagesStore.getState().comments[cid] : undefined);
|
||||||
|
|
||||||
|
const findLoadedCommentByNumber = ({ number, subplebbitAddress }: { number: number; subplebbitAddress: string }) => {
|
||||||
|
const comments = Object.values(subplebbitsPagesStore.getState().comments) as Array<Comment | undefined>;
|
||||||
|
|
||||||
|
return comments.find((comment) => comment?.subplebbitAddress === subplebbitAddress && comment?.number === number && comment?.cid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildResolvedTarget = ({
|
||||||
|
cid,
|
||||||
|
comment,
|
||||||
|
directories,
|
||||||
|
subplebbitAddress,
|
||||||
|
}: {
|
||||||
|
cid: string;
|
||||||
|
comment?: Comment;
|
||||||
|
directories: DirectoryCommunity[];
|
||||||
|
subplebbitAddress: string;
|
||||||
|
}): ResolvedExternalQuoteTarget => {
|
||||||
|
const boardPath = getBoardPath(subplebbitAddress, directories);
|
||||||
|
return {
|
||||||
|
boardPath,
|
||||||
|
cid,
|
||||||
|
comment,
|
||||||
|
isUnavailable: isUnavailableComment(comment),
|
||||||
|
route: `/${boardPath}/thread/${cid}`,
|
||||||
|
subplebbitAddress,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerComments = (comments: Comment[]) => {
|
||||||
|
if (!comments.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usePostNumberStore.getState().registerComments(comments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForBoardFeedPage = async (feedName: string, previousLength: number, expectedPageNumber: number) =>
|
||||||
|
waitFor(() => {
|
||||||
|
const state = feedsStore.getState();
|
||||||
|
const feed = state.loadedFeeds[feedName] ?? [];
|
||||||
|
const hasMore = state.feedsHaveMore[feedName];
|
||||||
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 0;
|
||||||
|
|
||||||
|
if (pageNumber < expectedPageNumber) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feed.length > previousLength || feed.length >= expectedPageNumber * BOARD_SEARCH_POSTS_PER_PAGE || hasMore === false) {
|
||||||
|
return { feed, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadBoardThreads = async ({
|
||||||
|
account,
|
||||||
|
number,
|
||||||
|
onStatus,
|
||||||
|
quoteDisplay,
|
||||||
|
subplebbitAddress,
|
||||||
|
directories,
|
||||||
|
}: {
|
||||||
|
account: ResolverAccount;
|
||||||
|
directories: DirectoryCommunity[];
|
||||||
|
number: number;
|
||||||
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
||||||
|
quoteDisplay: string;
|
||||||
|
subplebbitAddress: string;
|
||||||
|
}) => {
|
||||||
|
const accountId = account.id;
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error('Missing account id while resolving external quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardLabel = getExternalQuoteBoardLabel(
|
||||||
|
{
|
||||||
|
kind: 'same-board',
|
||||||
|
number,
|
||||||
|
raw: quoteDisplay,
|
||||||
|
subplebbitAddress,
|
||||||
|
},
|
||||||
|
directories,
|
||||||
|
);
|
||||||
|
|
||||||
|
onStatus?.({
|
||||||
|
phase: 'search-board',
|
||||||
|
boardLabel,
|
||||||
|
quoteDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedName = getBoardFeedName(accountId, subplebbitAddress);
|
||||||
|
const feedState = feedsStore.getState();
|
||||||
|
if (!feedState.feedsOptions[feedName]) {
|
||||||
|
await feedState.addFeedToStore(feedName, [subplebbitAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForBoardFeedPage(feedName, 0, 1);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const state = feedsStore.getState();
|
||||||
|
const feed = (state.loadedFeeds[feedName] ?? []) as Comment[];
|
||||||
|
const hasMore = state.feedsHaveMore[feedName];
|
||||||
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 1;
|
||||||
|
|
||||||
|
registerComments(feed);
|
||||||
|
|
||||||
|
const matchingThread = feed.find((thread) => thread.number === number);
|
||||||
|
if (matchingThread?.cid) {
|
||||||
|
return {
|
||||||
|
match: matchingThread,
|
||||||
|
threads: feed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
return {
|
||||||
|
match: undefined,
|
||||||
|
threads: feed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousLength = feed.length;
|
||||||
|
state.incrementFeedPageNumber(feedName);
|
||||||
|
await waitForBoardFeedPage(feedName, previousLength, pageNumber + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForRepliesPage = async (feedName: string, previousLength: number, expectedPageNumber: number) =>
|
||||||
|
waitFor(() => {
|
||||||
|
const state = repliesStore.getState();
|
||||||
|
const replies = state.loadedFeeds[feedName] ?? [];
|
||||||
|
const hasMore = state.feedsHaveMore[feedName];
|
||||||
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 0;
|
||||||
|
|
||||||
|
if (pageNumber < expectedPageNumber) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replies.length > previousLength || replies.length >= expectedPageNumber * THREAD_SEARCH_REPLIES_PER_PAGE || hasMore === false) {
|
||||||
|
return { hasMore, replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchThreadReplies = async ({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
number,
|
||||||
|
onStatus,
|
||||||
|
quoteDisplay,
|
||||||
|
subplebbitAddress,
|
||||||
|
threads,
|
||||||
|
}: {
|
||||||
|
account: ResolverAccount;
|
||||||
|
directories: DirectoryCommunity[];
|
||||||
|
number: number;
|
||||||
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
||||||
|
quoteDisplay: string;
|
||||||
|
subplebbitAddress: string;
|
||||||
|
threads: Comment[];
|
||||||
|
}) => {
|
||||||
|
const accountId = account.id;
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error('Missing account id while resolving external quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardLabel = getExternalQuoteBoardLabel(
|
||||||
|
{
|
||||||
|
kind: 'same-board',
|
||||||
|
number,
|
||||||
|
raw: quoteDisplay,
|
||||||
|
subplebbitAddress,
|
||||||
|
},
|
||||||
|
directories,
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidateThreads = threads.filter((thread) => thread?.cid && thread.replyCount !== 0);
|
||||||
|
|
||||||
|
for (const [index, thread] of candidateThreads.entries()) {
|
||||||
|
onStatus?.({
|
||||||
|
phase: 'search-thread',
|
||||||
|
boardLabel,
|
||||||
|
currentThread: index + 1,
|
||||||
|
quoteDisplay,
|
||||||
|
totalThreads: candidateThreads.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedOptions = {
|
||||||
|
accountId,
|
||||||
|
commentCid: thread.cid,
|
||||||
|
commentDepth: thread.depth,
|
||||||
|
flat: true,
|
||||||
|
postCid: thread.postCid ?? thread.cid,
|
||||||
|
repliesPerPage: THREAD_SEARCH_REPLIES_PER_PAGE,
|
||||||
|
sortType: THREAD_REPLIES_SORT_TYPE,
|
||||||
|
streamPage: true,
|
||||||
|
};
|
||||||
|
const feedName = feedOptionsToFeedName(feedOptions);
|
||||||
|
await repliesStore.getState().addFeedToStoreOrUpdateComment(thread, feedOptions);
|
||||||
|
await waitForRepliesPage(feedName, 0, 1);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const state = repliesStore.getState();
|
||||||
|
const replies = (state.loadedFeeds[feedName] ?? []) as Comment[];
|
||||||
|
const hasMore = state.feedsHaveMore[feedName];
|
||||||
|
const pageNumber = state.feedsOptions[feedName]?.pageNumber ?? 1;
|
||||||
|
|
||||||
|
registerComments(replies);
|
||||||
|
|
||||||
|
const matchingReply = replies.find((reply) => reply.number === number);
|
||||||
|
if (matchingReply?.cid) {
|
||||||
|
return matchingReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousLength = replies.length;
|
||||||
|
state.incrementFeedPageNumber(feedName);
|
||||||
|
await waitForRepliesPage(feedName, previousLength, pageNumber + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveExternalQuoteTarget = async ({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
onStatus,
|
||||||
|
reference,
|
||||||
|
}: {
|
||||||
|
account?: ResolverAccount | null;
|
||||||
|
directories: DirectoryCommunity[];
|
||||||
|
onStatus?: (status: ExternalQuoteSearchStatus) => void;
|
||||||
|
reference: ExternalQuoteReference;
|
||||||
|
}): Promise<ResolvedExternalQuoteTarget | null> => {
|
||||||
|
if (!account?.id) {
|
||||||
|
throw new Error('Missing active account while resolving external quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSubplebbitAddress = getExternalQuoteBoardAddress(reference, directories);
|
||||||
|
const quoteDisplay = reference.raw;
|
||||||
|
const cachedCid = usePostNumberStore.getState().numberToCid[targetSubplebbitAddress]?.[reference.number];
|
||||||
|
if (cachedCid) {
|
||||||
|
return buildResolvedTarget({
|
||||||
|
cid: cachedCid,
|
||||||
|
comment: getCachedComment(cachedCid),
|
||||||
|
directories,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedComment = findLoadedCommentByNumber({
|
||||||
|
number: reference.number,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
if (loadedComment?.cid) {
|
||||||
|
registerComments([loadedComment]);
|
||||||
|
return buildResolvedTarget({
|
||||||
|
cid: loadedComment.cid,
|
||||||
|
comment: loadedComment,
|
||||||
|
directories,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { match: matchingThread, threads } = await loadBoardThreads({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
number: reference.number,
|
||||||
|
onStatus,
|
||||||
|
quoteDisplay,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingThread?.cid) {
|
||||||
|
registerComments([matchingThread]);
|
||||||
|
return buildResolvedTarget({
|
||||||
|
cid: matchingThread.cid,
|
||||||
|
comment: matchingThread,
|
||||||
|
directories,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingReply = await searchThreadReplies({
|
||||||
|
account,
|
||||||
|
directories,
|
||||||
|
number: reference.number,
|
||||||
|
onStatus,
|
||||||
|
quoteDisplay,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
threads,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matchingReply?.cid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerComments([matchingReply]);
|
||||||
|
return buildResolvedTarget({
|
||||||
|
cid: matchingReply.cid,
|
||||||
|
comment: matchingReply,
|
||||||
|
directories,
|
||||||
|
subplebbitAddress: targetSubplebbitAddress,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { DirectoryCommunity } from '../../hooks/use-directories';
|
||||||
|
import { getBoardPath, getSubplebbitAddress } from './route-utils';
|
||||||
|
import { QUOTE_NUMBER_REGEX } from './url-utils';
|
||||||
|
|
||||||
|
const CROSSBOARD_NUMBER_BOARD_PART = '(?:[a-zA-Z0-9]{1,10}|12D3KooW[a-zA-Z0-9]{44}|[a-zA-Z0-9\\-.]+)';
|
||||||
|
|
||||||
|
const CROSSBOARD_NUMBER_QUOTE_REGEX = new RegExp(`>>>\\/(${CROSSBOARD_NUMBER_BOARD_PART})\\/(\\d+)(?=[^\\d]|$)`, 'g');
|
||||||
|
export const CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX = new RegExp(`>>>\\/(${CROSSBOARD_NUMBER_BOARD_PART})\\/(\\d+)[.,:;!?]*`);
|
||||||
|
|
||||||
|
export type SameBoardExternalQuoteReference = {
|
||||||
|
kind: 'same-board';
|
||||||
|
number: number;
|
||||||
|
raw: string;
|
||||||
|
subplebbitAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrossBoardExternalQuoteReference = {
|
||||||
|
kind: 'cross-board';
|
||||||
|
boardIdentifier: string;
|
||||||
|
number: number;
|
||||||
|
raw: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExternalQuoteReference = SameBoardExternalQuoteReference | CrossBoardExternalQuoteReference;
|
||||||
|
|
||||||
|
const getExternalQuoteKey = (reference: ExternalQuoteReference) =>
|
||||||
|
reference.kind === 'cross-board'
|
||||||
|
? `${reference.kind}:${reference.boardIdentifier}:${reference.number}`
|
||||||
|
: `${reference.kind}:${reference.subplebbitAddress}:${reference.number}`;
|
||||||
|
|
||||||
|
export const getExternalQuoteBoardAddress = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) =>
|
||||||
|
reference.kind === 'cross-board' ? getSubplebbitAddress(reference.boardIdentifier, directories) : reference.subplebbitAddress;
|
||||||
|
|
||||||
|
export const getExternalQuoteBoardLabel = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => {
|
||||||
|
const address = getExternalQuoteBoardAddress(reference, directories);
|
||||||
|
return getBoardPath(address, directories);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractUnresolvedExternalQuoteReferences = ({
|
||||||
|
content,
|
||||||
|
scopedNumberToCid,
|
||||||
|
subplebbitAddress,
|
||||||
|
}: {
|
||||||
|
content?: string;
|
||||||
|
scopedNumberToCid?: Record<number, string>;
|
||||||
|
subplebbitAddress?: string;
|
||||||
|
}) => {
|
||||||
|
if (!content) {
|
||||||
|
return [] as ExternalQuoteReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = new Map<string, ExternalQuoteReference>();
|
||||||
|
|
||||||
|
if (subplebbitAddress) {
|
||||||
|
for (const match of content.matchAll(new RegExp(QUOTE_NUMBER_REGEX.source, 'g'))) {
|
||||||
|
const number = Number.parseInt(match[1], 10);
|
||||||
|
if (Number.isNaN(number) || scopedNumberToCid?.[number]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference: SameBoardExternalQuoteReference = {
|
||||||
|
kind: 'same-board',
|
||||||
|
number,
|
||||||
|
raw: `>>${number}`,
|
||||||
|
subplebbitAddress,
|
||||||
|
};
|
||||||
|
references.set(getExternalQuoteKey(reference), reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of content.matchAll(new RegExp(CROSSBOARD_NUMBER_QUOTE_REGEX.source, 'g'))) {
|
||||||
|
const boardIdentifier = match[1];
|
||||||
|
const number = Number.parseInt(match[2], 10);
|
||||||
|
|
||||||
|
if (!boardIdentifier || Number.isNaN(number)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference: CrossBoardExternalQuoteReference = {
|
||||||
|
kind: 'cross-board',
|
||||||
|
boardIdentifier,
|
||||||
|
number,
|
||||||
|
raw: `>>>/${boardIdentifier}/${number}`,
|
||||||
|
};
|
||||||
|
references.set(getExternalQuoteKey(reference), reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...references.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExternalQuoteSearchStatus =
|
||||||
|
| {
|
||||||
|
phase: 'search-board';
|
||||||
|
boardLabel: string;
|
||||||
|
quoteDisplay: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
phase: 'search-thread';
|
||||||
|
boardLabel: string;
|
||||||
|
currentThread: number;
|
||||||
|
totalThreads: number;
|
||||||
|
quoteDisplay: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
phase: 'redirecting';
|
||||||
|
boardLabel: string;
|
||||||
|
quoteDisplay: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExternalQuoteStatusMessage = (t: (key: string, options?: Record<string, unknown>) => string, status: ExternalQuoteSearchStatus) => {
|
||||||
|
switch (status.phase) {
|
||||||
|
case 'search-board':
|
||||||
|
return t('external_quote_search_board_feed', { board: status.boardLabel });
|
||||||
|
case 'search-thread':
|
||||||
|
return t('external_quote_search_thread', {
|
||||||
|
board: status.boardLabel,
|
||||||
|
current: status.currentThread,
|
||||||
|
total: status.totalThreads,
|
||||||
|
});
|
||||||
|
case 'redirecting':
|
||||||
|
return t('external_quote_redirecting_to_post', { board: status.boardLabel });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -180,6 +180,12 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
|||||||
return true; // CID is exactly 46 alphanumeric chars
|
return true; // CID is exactly 46 alphanumeric chars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory + post number pattern: >>>/biz/123
|
||||||
|
const directoryPostNumberMatch = pathPart.match(/^([a-zA-Z0-9]{1,10})\/(\d+)$/);
|
||||||
|
if (directoryPostNumberMatch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a full address + thread pattern: >>>/board.eth/fullCid
|
// Check if it's a full address + thread pattern: >>>/board.eth/fullCid
|
||||||
const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/);
|
const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/);
|
||||||
if (addressThreadMatch) {
|
if (addressThreadMatch) {
|
||||||
@@ -188,6 +194,13 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
|||||||
return isValidDomain(address) || isValidIPNSKey(address);
|
return isValidDomain(address) || isValidIPNSKey(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a full address + post number pattern: >>>/board.eth/123
|
||||||
|
const addressPostNumberMatch = pathPart.match(/^([^/]+)\/(\d+)$/);
|
||||||
|
if (addressPostNumberMatch) {
|
||||||
|
const [, address] = addressPostNumberMatch;
|
||||||
|
return isValidDomain(address) || isValidIPNSKey(address);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's just a full address pattern: >>>/board.eth
|
// Check if it's just a full address pattern: >>>/board.eth
|
||||||
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
|
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ExternalQuoteStatusState {
|
||||||
|
message: string | null;
|
||||||
|
clearStatus: () => void;
|
||||||
|
setErrorStatus: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hideTimeout: number | null = null;
|
||||||
|
|
||||||
|
const clearHideTimeout = () => {
|
||||||
|
if (hideTimeout !== null) {
|
||||||
|
window.clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleHide = (clearStatus: () => void, delayMs: number) => {
|
||||||
|
clearHideTimeout();
|
||||||
|
hideTimeout = window.setTimeout(() => {
|
||||||
|
clearStatus();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useExternalQuoteStatusStore = create<ExternalQuoteStatusState>((set, get) => ({
|
||||||
|
message: null,
|
||||||
|
clearStatus: () => {
|
||||||
|
clearHideTimeout();
|
||||||
|
set({ message: null });
|
||||||
|
},
|
||||||
|
setErrorStatus: (message: string) => {
|
||||||
|
set({ message });
|
||||||
|
scheduleHide(get().clearStatus, 4000);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useExternalQuoteStatusStore;
|
||||||
@@ -42,6 +42,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
paginationFeedPostsPerPage: 6,
|
paginationFeedPostsPerPage: 6,
|
||||||
},
|
},
|
||||||
resetMock: vi.fn(),
|
resetMock: vi.fn(),
|
||||||
|
registerCommentsMock: vi.fn(),
|
||||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||||
setEnableInfiniteScrollMock: vi.fn(),
|
setEnableInfiniteScrollMock: vi.fn(),
|
||||||
setResetFunctionMock: vi.fn(),
|
setResetFunctionMock: vi.fn(),
|
||||||
@@ -147,6 +148,13 @@ vi.mock('../../../stores/use-feed-view-settings-store', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-post-number-store', () => ({
|
||||||
|
default: (selector: (state: { registerComments: typeof testState.registerCommentsMock }) => unknown) =>
|
||||||
|
selector({
|
||||||
|
registerComments: testState.registerCommentsMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
|
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
|
||||||
useBoardFeedPageSize: () => testState.pageSizes,
|
useBoardFeedPageSize: () => testState.pageSizes,
|
||||||
}));
|
}));
|
||||||
@@ -259,6 +267,7 @@ describe('Board', () => {
|
|||||||
};
|
};
|
||||||
testState.loadMoreMock.mockReset();
|
testState.loadMoreMock.mockReset();
|
||||||
testState.resetMock.mockReset();
|
testState.resetMock.mockReset();
|
||||||
|
testState.registerCommentsMock.mockReset();
|
||||||
testState.setEnableInfiniteScrollMock.mockReset();
|
testState.setEnableInfiniteScrollMock.mockReset();
|
||||||
testState.setResetFunctionMock.mockReset();
|
testState.setResetFunctionMock.mockReset();
|
||||||
document.title = 'before';
|
document.title = 'before';
|
||||||
@@ -333,6 +342,17 @@ describe('Board', () => {
|
|||||||
expect(latestLocation).toBe('/mu/2');
|
expect(latestLocation).toBe('/mu/2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers visible feed posts with the post-number store', async () => {
|
||||||
|
testState.feed = [
|
||||||
|
{ cid: 'first-post', subplebbitAddress: 'music-posting.eth' },
|
||||||
|
{ cid: 'second-post', subplebbitAddress: 'music-posting.eth' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' });
|
||||||
|
|
||||||
|
expect(testState.registerCommentsMock).toHaveBeenCalledWith(testState.feed);
|
||||||
|
});
|
||||||
|
|
||||||
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
|
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { subscriptions: [] };
|
||||||
testState.filteredDirectoryAddresses = [];
|
testState.filteredDirectoryAddresses = [];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbi
|
|||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
|
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
|
||||||
|
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||||
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
||||||
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
||||||
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
||||||
@@ -199,6 +200,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
|
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
|
||||||
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
|
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
|
||||||
);
|
);
|
||||||
|
const registerComments = usePostNumberStore((state) => state.registerComments);
|
||||||
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
|
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
|
||||||
const currentPageFeed = useMemo(
|
const currentPageFeed = useMemo(
|
||||||
() => (effectiveInfiniteScroll ? [] : getPageSlice(cappedFeed, currentPage, guiPostsPerPage, maxGuiPages)),
|
() => (effectiveInfiniteScroll ? [] : getPageSlice(cappedFeed, currentPage, guiPostsPerPage, maxGuiPages)),
|
||||||
@@ -239,6 +241,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
}
|
}
|
||||||
}, [filteredComments, reset]);
|
}, [filteredComments, reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (combinedFeed.length > 0) {
|
||||||
|
registerComments(combinedFeed);
|
||||||
|
}
|
||||||
|
}, [combinedFeed, registerComments]);
|
||||||
|
|
||||||
// Use stable subplebbit fields to avoid rerenders from updatingState
|
// Use stable subplebbit fields to avoid rerenders from updatingState
|
||||||
const subplebbitTitle = useSubplebbitField(subplebbitAddress, (sub) => sub?.title);
|
const subplebbitTitle = useSubplebbitField(subplebbitAddress, (sub) => sub?.title);
|
||||||
const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress);
|
const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress);
|
||||||
|
|||||||
Reference in New Issue
Block a user