Merge pull request #904 from bitsocialhq/development

Development
This commit is contained in:
Tom
2026-02-14 17:48:18 +08:00
committed by GitHub
60 changed files with 685 additions and 367 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ Bitsocial communities can require users to solve one or more anti-spam challenge
### Build
The Linux/Windows/macOS/Android build scripts are in [.github/workflows/release.yml](https://github.com/plebbit/5chan/blob/master/.github/workflows/release.yml)
The Linux/Windows/macOS/Android build scripts are in [.github/workflows/release.yml](https://github.com/bitsocialhq/5chan/blob/master/.github/workflows/release.yml)
## License
+1 -1
View File
@@ -11,7 +11,7 @@ const ipfsClientWindowsPath = path.join(ipfsClientsPath, 'win');
const ipfsClientMacPath = path.join(ipfsClientsPath, 'mac');
const ipfsClientLinuxPath = path.join(ipfsClientsPath, 'linux');
// plebbit kubu download links https://github.com/plebbit/kubo/releases
// plebbit kubo download links https://github.com/plebbit/kubo/releases
// const ipfsClientVersion = '0.20.0'
// const ipfsClientWindowsUrl = `https://github.com/plebbit/kubo/releases/download/v${ipfsClientVersion}/ipfs-windows-amd64`
// const ipfsClientMacUrl = `https://github.com/plebbit/kubo/releases/download/v${ipfsClientVersion}/ipfs-darwin-amd64`
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "في انتظار موافقة المشرف، غير مرئي للمستخدمين",
"copy_user_id": "نسخ معرف المستخدم",
"copy_direct_link": "نسخ الرابط المباشر"
"copy_direct_link": "نسخ الرابط المباشر",
"posts_auto_update_info": "يتم تحديث المنشورات تلقائياً كل ~2 دقيقة. لا يمكن تعطيل هذا في الوقت الحالي.",
"board_address_tooltip": "عنوان عقدة الند للند لهذه اللوحة"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "মডারেটর অনুমোদনের জন্য অপেক্ষা করছে, ব্যবহারকারীদের কাছে দৃশ্যমান নয়",
"copy_user_id": "ইউজার আইডি কপি করুন",
"copy_direct_link": "সরাসরি লিংক অনুলিপি করুন"
"copy_direct_link": "সরাসরি লিংক অনুলিপি করুন",
"posts_auto_update_info": "পোস্টগুলি প্রতি ~2 মিনিটে স্বয়ংক্রিয়ভাবে আপডেট হয়। বর্তমানে এটি অক্ষম করা সম্ভব নয়।",
"board_address_tooltip": "এই বোর্ডের পিয়ার-টু-পিয়ার নোডের ঠিকানা"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Čekání na schválení moderatorem, není viditelné uživatelům",
"copy_user_id": "kopírovat ID uživatele",
"copy_direct_link": "kopírovat přímý odkaz"
"copy_direct_link": "kopírovat přímý odkaz",
"posts_auto_update_info": "Příspěvky se aktualizují automaticky přibližně každých ~2 minut. V současné době není možné tuto funkci zakázat.",
"board_address_tooltip": "Adresa uzlu peer-to-peer této desky"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Afventer moderatorgodkendelse, ikke synlig for brugere",
"copy_user_id": "kopier bruger-ID",
"copy_direct_link": "kopier direkte link"
"copy_direct_link": "kopier direkte link",
"posts_auto_update_info": "Indlæg opdateres automatisk hver ~2 minut. Det er ikke muligt at deaktivere dette i øjeblikket.",
"board_address_tooltip": "Adressen til dette boards peer-to-peer-node"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "Nein",
"pending_mod_approval": "Ausstehende Moderatorgenehmigung, für Benutzer nicht sichtbar",
"copy_user_id": "Benutzer-ID kopieren",
"copy_direct_link": "Direktlink kopieren"
"copy_direct_link": "Direktlink kopieren",
"posts_auto_update_info": "Beiträge werden automatisch etwa alle ~2 Minuten aktualisiert. Es ist derzeit nicht möglich, dies zu deaktivieren.",
"board_address_tooltip": "Die Adresse des Peer-to-Peer-Knotens dieses Boards"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Σε αναμονή έγκρισης διαχειριστή, δεν είναι ορατό στους χρήστες",
"copy_user_id": "αντιγραφή ID χρήστη",
"copy_direct_link": "αντιγραφή άμεσου συνδέσμου"
"copy_direct_link": "αντιγραφή άμεσου συνδέσμου",
"posts_auto_update_info": "Οι δημοσιεύσεις ενημερώνονται αυτόματα περίπου κάθε ~2 λεπτά. Δεν είναι δυνατό να απενεργοποιήσετε αυτό τη στιγμή.",
"board_address_tooltip": "Η διεύθυνση του κόμβου peer-to-peer αυτού του πίνακα"
}
+3 -1
View File
@@ -70,6 +70,7 @@
"latest_stable_version": "You are on the latest stable version, 5chan v{{version}}.",
"check": "check",
"update": "Update",
"posts_auto_update_info": "Posts update automatically every ~2 minutes. It's not possible to disable this at the moment.",
"style": "Style",
"interface_language": "interface language",
"delete_confirm": "Are you sure you want to delete {{value}}?",
@@ -277,5 +278,6 @@
"no": "No",
"pending_mod_approval": "Pending mod approval, not visible to users",
"copy_user_id": "Copy user ID",
"copy_direct_link": "Copy direct link"
"copy_direct_link": "Copy direct link",
"board_address_tooltip": "The address of this board's peer-to-peer node"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Pendiente de aprobación del moderador, no visible para los usuarios",
"copy_user_id": "Copiar ID de usuario",
"copy_direct_link": "Copiar enlace directo"
"copy_direct_link": "Copiar enlace directo",
"posts_auto_update_info": "Los posts se actualizan automáticamente cada ~2 minutos. Por el momento, no es posible desactivar esto.",
"board_address_tooltip": "La dirección del nodo peer-to-peer de este tablón"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "در انتظار تایید مدیر، برای کاربران قابل مشاهده نیست",
"copy_user_id": "کپی شناسه کاربر",
"copy_direct_link": "رونوشت لینک مستقیم"
"copy_direct_link": "رونوشت لینک مستقیم",
"posts_auto_update_info": "پست‌ها هر ~2 دقیقه به‌طور خودکار به‌روز می‌شوند. فعلاً امکان غیرفعال کردن آن وجود ندارد.",
"board_address_tooltip": "آدرس گره هم‌تا-همتای این تخته"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Odottaa moderaattorin hyväksyntää, ei näkyvissä käyttäjille",
"copy_user_id": "kopioi käyttäjätunnus",
"copy_direct_link": "kopioi suora linkki"
"copy_direct_link": "kopioi suora linkki",
"posts_auto_update_info": "Viestit päivittyvät automaattisesti noin 2 minuutin välein. Tätä ei ole mahdollista poistaa käytöstä tällä hetkellä.",
"board_address_tooltip": "Tämän laudan vertaisverkko-solmun osoite"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Naghihintay ng pag-apruba ng moderator, hindi nakikita ng mga gumagamit",
"copy_user_id": "kopyahin ang user ID",
"copy_direct_link": "kopyahin ang direct link"
"copy_direct_link": "kopyahin ang direct link",
"posts_auto_update_info": "Ang mga post ay awtomatikong nag-update bawat ~2 minuto. Kasalukuyang hindi posible na i-disable ito.",
"board_address_tooltip": "Ang address ng peer-to-peer node ng board na ito"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "Non",
"pending_mod_approval": "En attente d'approbation du modérateur, non visible pour les utilisateurs",
"copy_user_id": "Copier l'ID utilisateur",
"copy_direct_link": "Copier le lien direct"
"copy_direct_link": "Copier le lien direct",
"posts_auto_update_info": "Les messages se mettent à jour automatiquement toutes les ~2 minutes. Il n'est pas possible de désactiver cela pour le moment.",
"board_address_tooltip": "L'adresse du nœud pair-à-pair de ce tableau"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "ממתין לאישור מנהל, לא ניתן לראות למשתמשים",
"copy_user_id": "העתק מזהה משתמש",
"copy_direct_link": "העתק קישור ישיר"
"copy_direct_link": "העתק קישור ישיר",
"posts_auto_update_info": "הפוסטים מתעדכנים באופן אוטומטי בערך כל ~2 דקות. כרגע לא ניתן להשבית זאת.",
"board_address_tooltip": "כתובת צומת ה-P2P של הלוח הזה"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "मॉडरेटर की मंजूरी के लिए प्रतीक्षा में, उपयोगकर्ताओं को दिखाई नहीं देता",
"copy_user_id": "यूज़र ID कॉपी करें",
"copy_direct_link": "डायरेक्ट लिंक कॉपी करें"
"copy_direct_link": "डायरेक्ट लिंक कॉपी करें",
"posts_auto_update_info": "पोस्ट्स हर ~2 मिनट में स्वचालित रूप से अपडेट होते हैं। फिलहाल इसे अक्षम करना संभव नहीं है।",
"board_address_tooltip": "इस बोर्ड के पीयर-टू-पीयर नोड का पता"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Moderátor jóváhagyása alatt áll, a felhasználók számára nem látható",
"copy_user_id": "felhasználói ID másolása",
"copy_direct_link": "közvetlen link másolása"
"copy_direct_link": "közvetlen link másolása",
"posts_auto_update_info": "A bejegyzések kb. 2 percenként automatikusan frissülnek. Jelenleg nem lehetséges ezt letiltani.",
"board_address_tooltip": "Ennek a táblának a peer-to-peer csomópontja címe"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Menunggu persetujuan moderator, tidak terlihat oleh pengguna",
"copy_user_id": "salin ID pengguna",
"copy_direct_link": "salin tautan langsung"
"copy_direct_link": "salin tautan langsung",
"posts_auto_update_info": "Posting diperbarui secara otomatis setiap ~2 menit. Saat ini tidak mungkin untuk menonaktifkan ini.",
"board_address_tooltip": "Alamat node peer-to-peer papan ini"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "In attesa di approvazione del moderatore, non visibile agli utenti",
"copy_user_id": "Copia ID utente",
"copy_direct_link": "Copia link diretto"
"copy_direct_link": "Copia link diretto",
"posts_auto_update_info": "I post si aggiornano automaticamente ogni ~2 minuti. Non è possibile disabilitare questa funzione al momento.",
"board_address_tooltip": "L'indirizzo del nodo peer-to-peer di questa bacheca"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "いいえ",
"pending_mod_approval": "モデレーターの承認待ち、ユーザーには表示されません",
"copy_user_id": "ユーザーIDをコピー",
"copy_direct_link": "直接リンクをコピー"
"copy_direct_link": "直接リンクをコピー",
"posts_auto_update_info": "投稿は約2分ごとに自動更新されます。現在、これを無効にすることはできません。",
"board_address_tooltip": "この板のピアツーピアノードのアドレス"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "중재자 승인 대기 중, 사용자에게 표시되지 않음",
"copy_user_id": "사용자 ID 복사",
"copy_direct_link": "직접 링크 복사"
"copy_direct_link": "직접 링크 복사",
"posts_auto_update_info": "게시물은 약 2분마다 자동으로 업데이트됩니다. 현재 이를 비활성화할 수 없습니다.",
"board_address_tooltip": "이 게시판의 P2P 노드 주소"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "संचालकाच्या मंजुरीची प्रतीक्षा, वापरकर्त्यांना दिसत नाही",
"copy_user_id": "यूजर ID कॉपी करा",
"copy_direct_link": "डायरेक्ट लिंक कॉपी करा"
"copy_direct_link": "डायरेक्ट लिंक कॉपी करा",
"posts_auto_update_info": "पोस्ट प्रत्येक ~2 मिनिटात स्वयंचलितपणे अपडेट होतात. सध्या हे अक्षम करणे शक्य नाही.",
"board_address_tooltip": "या बोर्डच्या पीअर-टू-पीअर नोडचा पत्ता"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "In afwachting van moderatorgoedkeuring, niet zichtbaar voor gebruikers",
"copy_user_id": "gebruikers-ID kopiëren",
"copy_direct_link": "directe link kopiëren"
"copy_direct_link": "directe link kopiëren",
"posts_auto_update_info": "Berichten worden automatisch ongeveer elke ~2 minuten bijgewerkt. Het is momenteel niet mogelijk dit uit te schakelen.",
"board_address_tooltip": "Het adres van de peer-to-peer-node van dit bord"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Venter på moderatorgodkjenning, ikke synlig for brukere",
"copy_user_id": "kopier bruker-ID",
"copy_direct_link": "kopier direkte lenke"
"copy_direct_link": "kopier direkte lenke",
"posts_auto_update_info": "Innlegg oppdateres automatisk omtrent hver ~2 minutt. Det er ikke mulig å deaktivere dette for øyeblikket.",
"board_address_tooltip": "Adressen til denne tavlens peer-to-peer-node"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Oczekiwanie na zatwierdzenie moderatora, niewidoczne dla użytkowników",
"copy_user_id": "skopiuj ID użytkownika",
"copy_direct_link": "skopiuj bezpośredni link"
"copy_direct_link": "skopiuj bezpośredni link",
"posts_auto_update_info": "Posty aktualizują się automatycznie co ~2 minuty. Chwilowo nie jest możliwe wyłączenie tej funkcji.",
"board_address_tooltip": "Adres węzła peer-to-peer tej tablicy"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "Não",
"pending_mod_approval": "Aguardando aprovação do moderador, não visível para os usuários",
"copy_user_id": "Copiar ID do usuário",
"copy_direct_link": "Copiar link direto"
"copy_direct_link": "Copiar link direto",
"posts_auto_update_info": "Posts são atualizados automaticamente a cada ~2 minutos. Não é possível desabilitar isso no momento.",
"board_address_tooltip": "O endereço do nó peer-to-peer desta placa"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "În așteptarea aprobării moderatorului, nu este vizibil pentru utilizatori",
"copy_user_id": "copiați ID-ul utilizatorului",
"copy_direct_link": "copiați linkul direct"
"copy_direct_link": "copiați linkul direct",
"posts_auto_update_info": "Postările se actualizează automat la fiecare ~2 minute. În prezent, nu este posibil să dezactivezi aceasta.",
"board_address_tooltip": "Adresa nodului peer-to-peer al acestei plăci"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "Нет",
"pending_mod_approval": "Ожидает одобрения модератора, не видно пользователям",
"copy_user_id": "копировать ID пользователя",
"copy_direct_link": "копировать прямую ссылку"
"copy_direct_link": "копировать прямую ссылку",
"posts_auto_update_info": "Посты обновляются автоматически примерно каждые ~2 минуты. В данный момент невозможно отключить эту функцию.",
"board_address_tooltip": "Адрес пирингового узла этой доски"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Duke pritur miratimin e moderatorit, jo i dukshëm për përdoruesit",
"copy_user_id": "kopjo ID e përdoruesit",
"copy_direct_link": "kopjo lidhjen e drejtpërdrejtë"
"copy_direct_link": "kopjo lidhjen e drejtpërdrejtë",
"posts_auto_update_info": "Postimet përditësohen automatikisht çdo ~2 minuta. Nuk është e mundur ta çaktivizoni këtë për momentin.",
"board_address_tooltip": "Adresa e nyjës peer-to-peer të kësaj tabele"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Väntar på moderatorgodkännande, inte synligt för användare",
"copy_user_id": "kopiera användar-ID",
"copy_direct_link": "kopiera direktlänk"
"copy_direct_link": "kopiera direktlänk",
"posts_auto_update_info": "Inlägg uppdateras automatiskt ungefär var ~2:e minut. Det är för närvarande inte möjligt att inaktivera detta.",
"board_address_tooltip": "Adressen till denna tavlas peer-to-peer-nod"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "మాడరేటర్ ఆమోదం కోసం చేతనిస్తోంది, వినియోగదారులకు కనిపించవు",
"copy_user_id": "యూజర్ ID కాపీ చేయండి",
"copy_direct_link": "డైరెక్ట్ లింక్ కాపీ చేయండి"
"copy_direct_link": "డైరెక్ట్ లింక్ కాపీ చేయండి",
"posts_auto_update_info": "పోస్టులు ప్రతి ~2 నిమిషాలకు స్వయంచాలకంగా నవీకరించబడతాయి. ప్రస్తుతం దీనిని నిలిపివేయడం సম్భవం కాదు.",
"board_address_tooltip": "ఈ బోర్డ్ యొక్క పీర్-టు-పీర్ నోడ్ యొక్క చిరునామా"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "รอการอนุมัติจากผู้ดูแล ไม่มองเห็นสำหรับผู้ใช้",
"copy_user_id": "คัดลอก ID ผู้ใช้",
"copy_direct_link": "คัดลอกลิงก์โดยตรง"
"copy_direct_link": "คัดลอกลิงก์โดยตรง",
"posts_auto_update_info": "โพสต์จะอัปเดตโดยอัตโนมัติทุก ~2 นาที ไม่สามารถปิดการใช้งานนี้ได้ในขณะนี้",
"board_address_tooltip": "ที่อยู่โหนด P2P ของบอร์ดนี้"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Moderatör onayı beklemede, kullanıcılara görünmüyor",
"copy_user_id": "kullanıcı ID'sini kopyala",
"copy_direct_link": "doğrudan bağlantıyı kopyala"
"copy_direct_link": "doğrudan bağlantıyı kopyala",
"posts_auto_update_info": "Gönderiler her ~2 dakikada otomatik olarak güncellenir. Şu anda bunu devre dışı bırakmak mümkün değildir.",
"board_address_tooltip": "Bu panonun eşler arası düğüm adresi"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Очікує затвердження модератора, не видно користувачам",
"copy_user_id": "копіювати ID користувача",
"copy_direct_link": "копіювати пряме посилання"
"copy_direct_link": "копіювати пряме посилання",
"posts_auto_update_info": "Дописи оновлюються автоматично приблизно кожні ~2 хвилини. В даний момент неможливо вимкнути цю функцію.",
"board_address_tooltip": "Адреса пірінгового вузла цієї дошки"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "ماڈریٹر کی منظوری کا انتظار، صارفین کے لیے نظر نہیں آتا",
"copy_user_id": "صارف کی شناخت کاپی کریں",
"copy_direct_link": "براہ راست لنک کاپی کریں"
"copy_direct_link": "براہ راست لنک کاپی کریں",
"posts_auto_update_info": "پوسٹس ہر ~2 منٹ میں خود بخود اپ ڈیٹ ہوتے ہیں۔ فی الوقت اس کو غیر فعال کرنا ممکن نہیں ہے۔",
"board_address_tooltip": "اس بورڈ کے پیر ٹو پیر نوڈ کا پتہ"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "No",
"pending_mod_approval": "Chờ duyệt của người điều hành, không hiển thị cho người dùng",
"copy_user_id": "sao chép ID người dùng",
"copy_direct_link": "sao chép liên kết trực tiếp"
"copy_direct_link": "sao chép liên kết trực tiếp",
"posts_auto_update_info": "Bài đăng được cập nhật tự động cứ ~2 phút một lần. Hiện tại không thể tắt tính năng này.",
"board_address_tooltip": "Địa chỉ nút peer-to-peer của bảng này"
}
+3 -1
View File
@@ -277,5 +277,7 @@
"no": "否",
"pending_mod_approval": "等待版主批准,用户不可见",
"copy_user_id": "复制用户ID",
"copy_direct_link": "复制直接链接"
"copy_direct_link": "复制直接链接",
"posts_auto_update_info": "帖子每约2分钟自动更新一次。目前无法禁用此功能。",
"board_address_tooltip": "此板块的点对点节点地址"
}
+1 -1
View File
@@ -24,7 +24,7 @@ SCRIPT="
cd ~
rm $FIVECHAN_HTML_NAME.zip
rm -fr $FIVECHAN_HTML_NAME
wget https://github.com/plebbit/5chan/releases/download/v$FIVECHAN_VERSION/$FIVECHAN_HTML_NAME.zip || exit
wget https://github.com/bitsocialhq/5chan/releases/download/v$FIVECHAN_VERSION/$FIVECHAN_HTML_NAME.zip || exit
# extract html
unzip $FIVECHAN_HTML_NAME.zip || exit
+2 -2
View File
@@ -49,7 +49,7 @@ if (remoteFiles.length) {
files = remoteFiles;
}
const linkTo = (file) => `https://github.com/plebbit/5chan/releases/download/v${version}/${file}`;
const linkTo = (file) => `https://github.com/bitsocialhq/5chan/releases/download/v${version}/${file}`;
const has = (s, sub) => s.toLowerCase().includes(sub);
const isArm = (s) => has(s, 'arm64') || has(s, 'aarch64');
const isX64 = (s) => has(s, 'x64') || has(s, 'x86_64') || !isArm(s);
@@ -102,7 +102,7 @@ const htmlSection = section('Static HTML build', [htmlZip && `- 5chan-html (zip)
const downloads = [macSection, winSection, linuxSection, androidSection, htmlSection].filter(Boolean).join('\n\n');
// One-liner summary of what changed in this release. Update before each release.
const oneLinerDescription = 'This version adds backlinks for quoted posts, improves reply functionality with proper quote references, and includes several bug fixes.';
const oneLinerDescription = 'This version enables the Update button on the post page to refresh replies, adds an informational alert for the Auto checkbox, fixes inline quote link layout, and includes several bug fixes.';
const releaseBody = `${oneLinerDescription}
+8 -23
View File
@@ -147,47 +147,32 @@ const RefreshButton = () => {
const UpdateButton = () => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const handleManualUpdate = () => {
window.alert('Manual updates are not available yet. Posts update automatically every ~2 minutes.');
};
const reset = useFeedResetStore((state) => state.reset);
return (
<>
{/* TODO: Implement update button once available in API */}
{isMobile ? (
<button className={`button ${styles.disabledButton}`} onClick={handleManualUpdate}>
{t('update')}
</button>
) : (
<button className={`button ${styles.disabledButton}`} onClick={handleManualUpdate}>
{t('update')}
</button>
)}
</>
<button className='button' onClick={() => reset?.()}>
{t('update')}
</button>
);
};
const AutoButton = () => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const handleManualUpdate = () => {
window.alert('Manual updates are not available yet. Posts update automatically every ~2 minutes.');
const handleAutoClick = () => {
window.alert(t('posts_auto_update_info'));
};
return (
<>
{isMobile ? (
<button className='button' onClick={handleManualUpdate}>
<button className='button' onClick={handleAutoClick}>
<label>
<input type='checkbox' className={styles.autoCheckbox} checked disabled />
{t('Auto')}
</label>
</button>
) : (
<label onClick={handleManualUpdate}>
<label onClick={handleAutoClick}>
{' '}
<input type='checkbox' className={styles.autoCheckbox} checked disabled /> {t('Auto')}
</label>
@@ -103,6 +103,8 @@ const BoardHeader = () => {
<span className={styles.clickableSubtitle} onClick={() => navigate('/subs/settings#subscriptions-settings')}>
{subtitle}
</span>
) : !isInAllView && !isInModView && subtitle ? (
<span title={t('board_address_tooltip')}>{subtitle}</span>
) : (
subtitle
)}
@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Comment, useComment } from '@plebbit/plebbit-react-hooks';
@@ -25,6 +25,41 @@ const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => {
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotedComment} isOP={isOP} />;
};
const useScopedCidToNumber = (cids: string[]) => {
const sortedUniqueCids = useMemo(() => {
const uniqueCids = new Set<string>();
for (const cid of cids) {
if (cid) {
uniqueCids.add(cid);
}
}
return [...uniqueCids].sort();
}, [cids]);
// Subscribe only to CIDs this comment needs so unrelated thread updates do not rerender content.
const cidNumbersSignature = usePostNumberStore(
useCallback((state) => sortedUniqueCids.map((cid) => `${cid}:${state.cidToNumber[cid] ?? ''}`).join('|'), [sortedUniqueCids]),
);
return useMemo(() => {
if (sortedUniqueCids.length === 0) {
return {} as Record<string, number>;
}
const { cidToNumber } = usePostNumberStore.getState();
const nextCidToNumber: Record<string, number> = {};
for (const cid of sortedUniqueCids) {
const number = cidToNumber[cid];
if (typeof number === 'number') {
nextCidToNumber[cid] = number;
}
}
return nextCidToNumber;
}, [sortedUniqueCids, cidNumbersSignature]);
};
const CommentContent = ({ comment: post }: { comment: Comment }) => {
const { t } = useTranslation();
const params = useParams();
@@ -59,7 +94,15 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
return new Set([...matches].map((m) => parseInt(m[1], 10)));
}, [content]);
const cidToNumber = usePostNumberStore((state) => state.cidToNumber);
const relevantQuotedCids = useMemo(() => {
const cids = quotedCids ? [...quotedCids] : [];
if (parentCid) {
cids.push(parentCid);
}
return cids;
}, [quotedCids, parentCid]);
const cidToNumber = useScopedCidToNumber(relevantQuotedCids);
const filteredQuotedCids = useMemo(() => {
if (!quotedCids?.length) return [];
return quotedCids.filter((cid: string) => {
@@ -71,13 +114,16 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
const shouldShowReplyingToReply = isReplyingToReply && (parentCid ? !contentNumbers.has(cidToNumber[parentCid] ?? -1) : true);
const stateString = useStateString(post);
const hasFailedState = state === 'failed';
const loadingString = <div className={styles.stateString}>{stateString !== 'Failed' ? <LoadingEllipsis string={stateString || t('loading')} /> : stateString}</div>;
const loadingString = (
<div className={styles.stateString}>{!hasFailedState ? <LoadingEllipsis string={stateString || t('loading')} /> : stateString || capitalize(t('failed'))}</div>
);
return (
<blockquote className={`${styles.postMessage} ${!isReply && isMobile && styles.clampLines}`}>
{isReply &&
state !== 'failed' &&
!hasFailedState &&
!(deleted || removed) &&
(filteredQuotedCids.length > 0
? filteredQuotedCids.map((cid: string) => <QuotedCidLink key={cid} cid={cid} postCid={postCid} />)
@@ -163,7 +209,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
/>
</span>
)}
{!cid && state === 'pending' && stateString !== 'Failed' && (
{!cid && !hasFailedState && (
<>
<br />
<br />
@@ -25,13 +25,9 @@ const CreateBoardModal = () => {
<div className={styles.section}>
<h3>Creating Your Board</h3>
<p>
Create a board using the{' '}
<a href='https://plebbit.github.io/docs/learn/clients/5chan/create-a-board' target='_blank' rel='noopener noreferrer'>
Seedit GUI client
</a>{' '}
or{' '}
<a href='https://github.com/plebbit/plebbit-cli' target='_blank' rel='noopener noreferrer'>
plebbit-cli
Create a board using the CLI:
<a href='https://github.com/bitsocialhq/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
bitsocial-cli
</a>
. <strong>Build a following:</strong> Users can subscribe to your board via the &quot;[Subscribe]&quot; button, which adds it to their top bar. You can gain
subscribers through direct links, word of mouth, or searchno directory assignment or dev approval needed.
@@ -42,7 +38,7 @@ const CreateBoardModal = () => {
<h3>Submitting to a Directory</h3>
<p>
Open a PR editing{' '}
<a href='https://github.com/plebbit/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
5chan-directories.json
</a>{' '}
with your board&apos;s title, address, and NSFW status. Devs will review and merge if approved. 99% uptime is required.
@@ -32,13 +32,9 @@ const DirectoryModal = () => {
<div className={styles.section}>
<h3>Creating Your Board</h3>
<p>
<strong>Anyone can create a board</strong> using the{' '}
<a href='https://plebbit.github.io/docs/learn/clients/5chan/create-a-board' target='_blank' rel='noopener noreferrer'>
Seedit GUI client
</a>{' '}
or{' '}
<a href='https://github.com/plebbit/plebbit-cli' target='_blank' rel='noopener noreferrer'>
plebbit-cli
<strong>Anyone can create a board</strong> using the official CLI:{' '}
<a href='https://github.com/bitsocialhq/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
bitsocial-cli
</a>
. Users can access it anytime via the search bar, direct links, or by subscribing with the &quot;[Subscribe]&quot; button
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like &quot;Anime &
@@ -50,7 +46,7 @@ const DirectoryModal = () => {
<h3>Directory Submission</h3>
<p>
To submit your board, open a PR editing{' '}
<a href='https://github.com/plebbit/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
5chan-directories.json
</a>{' '}
with your board&apos;s title, address, and NSFW status. Requirements: 99% uptime (they&apos;re P2P nodes), active, well-moderated, and relevant to the
+30 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import {
@@ -33,30 +33,22 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
const authorDisplayName = post?.author?.displayName;
const modBanExpiresAt = post?.commentModeration?.author?.banExpiresAt;
const purged = post?.commentModeration?.purged ?? false;
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const account = useAccount();
const [signer, setSigner] = useState<any>(account?.signer);
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
commentAuthorAddress: author?.address,
subplebbitAddress,
postCid,
});
const checkSigner = useCallback(() => {
if (isAccountCommentAuthor) {
setSigner(account?.signer);
} else {
setSigner(null);
}
}, [isAccountCommentAuthor, account?.signer]);
useEffect(() => {
checkSigner();
}, [checkSigner]);
const signer = isAccountCommentAuthor ? account?.signer : null;
const latestPostRef = useRef(post);
latestPostRef.current = post;
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
const defaultPublishEditOptions = useMemo(() => {
return {
@@ -75,17 +67,17 @@ const EditMenu = ({ post }: { post: Comment }) => {
purged: purged ?? false,
spoiler: spoiler ?? false,
reason,
author: post?.commentModeration?.author?.banExpiresAt ? { banExpiresAt: post.commentModeration.author.banExpiresAt } : undefined,
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
}
: undefined,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
};
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, post]);
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, modBanExpiresAt, onChallenge]);
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
@@ -94,19 +86,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
commentCid: cid,
subplebbitAddress,
signer,
author: signer?.address === author?.address ? { address: signer?.address, displayName: post?.author?.displayName } : account?.author,
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
content: publishCommentEditOptions.content,
deleted: publishCommentEditOptions.deleted,
reason: publishCommentEditOptions.reason,
spoiler: publishCommentEditOptions.spoiler,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, signer, post, account?.author, author?.address],
[publishCommentEditOptions, cid, subplebbitAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge],
);
const modEditOptions = useMemo<PublishCommentModerationOptions>(
@@ -123,27 +115,31 @@ const EditMenu = ({ post }: { post: Comment }) => {
author: publishCommentEditOptions.commentModeration?.author,
},
author: account?.author,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment moderation failed. ' + error.message);
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, post, account?.author, parentCid],
[publishCommentEditOptions, cid, subplebbitAddress, account?.author, parentCid, onChallenge],
);
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
useEffect(() => {
setPublishCommentEditOptions(defaultPublishEditOptions);
}, [defaultPublishEditOptions]);
const [banDuration, setBanDuration] = useState(() =>
publishCommentEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(publishCommentEditOptions.commentModeration.author.banExpiresAt) : 1,
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
const resetMenuState = () => {
setPublishCommentEditOptions(defaultPublishEditOptions);
setBanDuration(
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
setIsContentEditorOpen(false);
};
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, checked } = e.target;
@@ -258,7 +254,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
type='checkbox'
onChange={() => {
if (cid && (isAccountCommentAuthor || isAccountMod)) {
setIsEditMenuOpen(!isEditMenuOpen);
if (!isEditMenuOpen) {
resetMenuState();
setIsEditMenuOpen(true);
} else {
setIsEditMenuOpen(false);
}
} else {
setIsEditMenuOpen(false);
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
+57 -50
View File
@@ -179,7 +179,7 @@ const NumberQuoteLink = ({ number, threadPostCid }: { number: number; threadPost
return <span>{`>>${number}`}</span>;
}
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} isOP={isOP} />;
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} isOP={isOP} showTrailingBreak={false} />;
};
const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string) => {
@@ -247,14 +247,18 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
};
const Markdown = ({ content, title, postCid }: MarkdownProps) => {
const remarkPlugins: any[] = [[supersub]];
const remarkPlugins = useMemo(() => {
const plugins: any[] = [[supersub]];
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
remarkPlugins.push([remarkGfm, { singleTilde: false }]);
}
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
plugins.push([remarkGfm, { singleTilde: false }]);
}
remarkPlugins.push([blockquoteToGreentext]);
remarkPlugins.push([spoilerTransform]);
plugins.push([blockquoteToGreentext]);
plugins.push([spoilerTransform]);
return plugins;
}, [content]);
const customSchema = useMemo(
() => ({
@@ -270,10 +274,53 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
[],
);
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
const location = useLocation();
const params = useParams();
const isInCatalogView = isCatalogView(location.pathname, params);
const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]);
// Preprocess content to convert plain text 5chan patterns to markdown links
const processedContent = preprocess5chanPatterns(content || '');
const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]);
const components = useMemo(
() =>
({
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
h1: ({ children }) => <p className={styles.header}>{children}</p>,
h2: ({ children }) => <p className={styles.header}>{children}</p>,
h3: ({ children }) => <p className={styles.header}>{children}</p>,
h4: ({ children }) => <p className={styles.header}>{children}</p>,
h5: ({ children }) => <p className={styles.header}>{children}</p>,
h6: ({ children }) => <p className={styles.header}>{children}</p>,
img: ({ src, alt }) => {
const displayText = src || alt || 'image';
return <span>{displayText}</span>;
},
video: ({ src }) => <span>{src}</span>,
iframe: ({ src }) => <span>{src}</span>,
source: ({ src }) => <span>{src}</span>,
spoiler: ({ children }) => <span className='spoilertext'>{children}</span>,
a: ({ href, children }) => {
if (href && !isInCatalogView) {
try {
const linkMediaInfo = getLinkMediaInfo(href);
const embedUrl = href.startsWith('http') ? new URL(href) : null;
if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) {
return <ContentLinkEmbed children={children} href={href} linkMediaInfo={linkMediaInfo} />;
}
} catch (e) {
console.debug('Invalid URL:', href);
}
return renderAnchorLink(children, href, postCid);
}
return renderAnchorLink(children, href || '', postCid);
},
}) as ExtendedComponents,
[isInCatalogView, postCid],
);
return (
<span className={styles.markdown}>
@@ -283,47 +330,7 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
{content ? ': ' : ''}
</span>
)}
<ReactMarkdown
children={processedContent}
remarkPlugins={remarkPlugins}
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize, customSchema]]}
components={
{
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
h1: ({ children }) => <p className={styles.header}>{children}</p>,
h2: ({ children }) => <p className={styles.header}>{children}</p>,
h3: ({ children }) => <p className={styles.header}>{children}</p>,
h4: ({ children }) => <p className={styles.header}>{children}</p>,
h5: ({ children }) => <p className={styles.header}>{children}</p>,
h6: ({ children }) => <p className={styles.header}>{children}</p>,
img: ({ src, alt }) => {
const displayText = src || alt || 'image';
return <span>{displayText}</span>;
},
video: ({ src }) => <span>{src}</span>,
iframe: ({ src }) => <span>{src}</span>,
source: ({ src }) => <span>{src}</span>,
spoiler: ({ children }) => <span className='spoilertext'>{children}</span>,
a: ({ href, children }) => {
if (href && !isInCatalogView) {
try {
const linkMediaInfo = getLinkMediaInfo(href);
const embedUrl = href.startsWith('http') ? new URL(href) : null;
if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) {
return <ContentLinkEmbed children={children} href={href} linkMediaInfo={linkMediaInfo} />;
}
} catch (e) {
console.debug('Invalid URL:', href);
}
return renderAnchorLink(children, href, postCid);
}
return renderAnchorLink(children, href || '', postCid);
},
} as ExtendedComponents
}
/>
<ReactMarkdown children={processedContent} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components} />
</span>
);
};
+149 -79
View File
@@ -2,13 +2,13 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Comment, useAuthorAvatar, useEditedComment, useReplies, useAccount } from '@plebbit/plebbit-react-hooks';
import { Comment, useAuthorAvatar, useEditedComment, useReplies, useAccount, useAccountComment } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js';
import styles from '../../views/post/post.module.css';
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isValidURL, QUOTE_NUMBER_REGEX } from '../../lib/utils/url-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -38,9 +38,11 @@ import { shouldShowSnow } from '../../lib/snow';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import usePostNumberStore from '../../stores/use-post-number-store';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { usePublishCommentModeration } from '@plebbit/plebbit-react-hooks';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
const { addChallenge } = useChallengesStore.getState();
@@ -76,15 +78,15 @@ const PostInfo = ({
onApprove,
onReject,
quotedByMap,
}: PostProps) => {
directRepliesByParentCid,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
const { t } = useTranslation();
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
const title = post?.title?.trim();
const { replies } = useReplies({ comment: post, accountComments: { newerThan: Infinity } });
const { address, shortAddress } = author || {};
const displayName = author?.displayName?.trim();
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
const stateString = useStateString(post);
const hasFailedState = state === 'failed';
const isReply = parentCid;
const { showOmittedReplies } = useShowOmittedReplies();
const { imageUrl: avatarImageUrl } = useAuthorAvatar({ author });
@@ -203,12 +205,18 @@ const PostInfo = ({
const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
const showUserID = pseudonymityMode !== 'per-reply';
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = useMemo(() => {
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
return 0;
}
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
}, [showUserID, deleted, removed, shortAddress, postCid]);
const { hidden } = useHide(post);
const { openReplyModal } = useReplyModalStore();
@@ -327,9 +335,7 @@ const PostInfo = ({
) : (
<>
<span>No.</span>
<span className={styles.pendingCid}>
{state === 'failed' || stateString === 'Failed' ? capitalize(t('failed')) : state === 'pending' ? capitalize(t('pending')) : ''}
</span>
<span className={styles.pendingCid}>{hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))}</span>
</>
)}
{pinned && (
@@ -419,38 +425,57 @@ const PostInfo = ({
)}
</span>
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{cid &&
parentCid &&
replies &&
replies.map(
(reply: Comment, index: number) =>
reply?.parentCid === cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
)}
{cid &&
parentCid &&
quotedByMap
?.get(cid)
?.map(
(reply: Comment, index: number) =>
reply?.parentCid !== cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
{cid &&
!parentCid &&
quotedByMap
?.get(cid)
?.map(
(reply: Comment) =>
reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`op-bl-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
{cid && !parentCid && <OpBacklinks cid={cid} quotedByMap={quotedByMap} />}
</span>
</div>
);
};
const ReplyBacklinks = ({
post,
quotedByMap,
directRepliesByParentCid,
}: {
post: Comment;
quotedByMap?: Map<string, Comment[]>;
directRepliesByParentCid?: Map<string, Comment[]>;
}) => {
const { cid, parentCid } = post || {};
if (!cid || !parentCid) {
return null;
}
const directReplies = directRepliesByParentCid?.get(cid) || [];
return (
<>
{directReplies.map(
(reply: Comment, index: number) =>
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
)}
{quotedByMap
?.get(cid)
?.map(
(reply: Comment, index: number) =>
reply?.parentCid !== cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
</>
);
};
const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map<string, Comment[]> }) => (
<>
{quotedByMap
?.get(cid)
?.map(
(reply: Comment) =>
reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`op-bl-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
</>
);
interface PostMediaProps {
commentMediaInfo: CommentMediaInfo | undefined;
hasThumbnail: boolean;
@@ -548,10 +573,21 @@ const PostMedia = ({
);
};
const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: PostProps) => {
let post = reply;
const Reply = ({
postReplyCount,
reply,
roles,
threadNumber,
quotedByMap,
directRepliesByParentCid,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
const accountReply = useAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
const hasReplyIndex = typeof reply?.index === 'number';
let post = hasReplyIndex && accountReply?.index === reply.index ? accountReply : reply;
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment: reply });
const { editedComment } = useEditedComment({ comment: post });
if (editedComment) {
post = editedComment;
}
@@ -576,7 +612,15 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: Post
<div className={styles.replyDesktop}>
<div className={styles.sideArrows}>{'>>'}</div>
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
<PostInfo post={post} postReplyCount={postReplyCount} roles={roles} isHidden={hidden} threadNumber={threadNumber} quotedByMap={quotedByMap} />
<PostInfo
post={post}
postReplyCount={postReplyCount}
roles={roles}
isHidden={hidden}
threadNumber={threadNumber}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
{link && !hidden && !(deleted || removed) && isValidURL(link) && (
<PostMedia
commentMediaInfo={commentMediaInfo}
@@ -626,12 +670,28 @@ const PostDesktop = ({
const { hidden, unhide, hide } = useHide({ cid });
const isHidden = hidden && !isInPostPageView;
const { replies, hasMore, loadMore } = useReplies({ comment: post, flat: true, accountComments: { newerThan: Infinity } });
const repliesResult = useReplies({
comment: post,
sortType: 'old',
flat: true,
accountComments: { newerThan: Infinity, append: true },
});
const { replies, hasMore, loadMore } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
const reset = (repliesResult as { reset?: () => Promise<void> }).reset;
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
useEffect(() => {
if ((isInPostPageView || isInPendingPostView) && reset) {
setResetFunction(() => {
reset();
});
}
}, [isInPostPageView, isInPendingPostView, reset, setResetFunction]);
const registerComments = usePostNumberStore((s) => s.registerComments);
const numberToCid = usePostNumberStore((s) => s.numberToCid);
const prevCidsRef = useRef<string>('');
useEffect(() => {
const all = post ? [post, ...(replies || [])] : replies || [];
const all = post ? [post, ...repliesForRender] : repliesForRender;
if (!all.length) return;
const cidsKey = all
.map((c) => c?.cid)
@@ -641,53 +701,41 @@ const PostDesktop = ({
if (cidsKey === prevCidsRef.current) return;
prevCidsRef.current = cidsKey;
registerComments(all);
}, [post, replies, registerComments]);
}, [post, repliesForRender, registerComments]);
const visiblelinksCount = useCountLinksInReplies(post, 5);
const totalLinksCount = useCountLinksInReplies(post);
const replyCount = replies?.length;
const replyCount = repliesForRender.length;
const repliesCount = pinned ? replyCount : replyCount - 5;
const linksCount = pinned ? totalLinksCount : totalLinksCount - visiblelinksCount;
const { showOmittedReplies, setShowOmittedReplies } = useShowOmittedReplies();
const stateString = useStateString(post) || t('downloading_board');
const hasFailedState = state === 'failed';
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
const filteredReplies = useMemo(() => (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]);
// Compute quotedByMap: map each quoted CID to array of replies that quote it
const quotedByMap = useMemo(() => {
const filteredReplies = useMemo(() => repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [repliesForRender]);
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const cidSet = new Set<string>();
if (reply.quotedCids?.length) {
for (const quotedCid of reply.quotedCids) {
cidSet.add(quotedCid);
}
const directParentCid = reply?.parentCid;
if (!directParentCid || !reply?.cid) {
continue;
}
if (reply.content) {
for (const match of reply.content.matchAll(QUOTE_NUMBER_REGEX)) {
const postNumber = parseInt(match[1], 10);
const quotedCid = numberToCid[postNumber];
if (quotedCid) {
cidSet.add(quotedCid);
}
}
}
for (const quotedCid of cidSet) {
const arr = map.get(quotedCid);
if (arr) arr.push(reply);
else map.set(quotedCid, [reply]);
const existingReplies = map.get(directParentCid);
if (existingReplies) {
existingReplies.push(reply);
} else {
map.set(directParentCid, [reply]);
}
}
return map;
}, [filteredReplies, numberToCid]);
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies);
// Virtuoso scroll position management for infinite replies
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
@@ -778,6 +826,7 @@ const PostDesktop = ({
onApprove={onApprove}
onReject={onReject}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
{!isHidden && <CommentContent comment={post} />}
@@ -815,7 +864,14 @@ const PostDesktop = ({
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
)}
useWindowScroll={true}
@@ -834,7 +890,14 @@ const PostDesktop = ({
!hasMore &&
filteredReplies.map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
))}
{/* Non-virtualized rendering for board view (last 5 replies or show omitted) */}
@@ -842,21 +905,28 @@ const PostDesktop = ({
!showAllReplies &&
!(pinned && !isInPostPageView && !showOmittedReplies[cid]) &&
!isInPendingPostView &&
replies &&
repliesForRender &&
showReplies &&
(showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
))}
</div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
{!isInPendingPostView && stateString && !hasFailedState && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<div className={styles.stateString}>
<br />
<LoadingEllipsis string={stateString} />
</div>
) : (
state === 'failed' && <span className={styles.error}>{t('failed')}</span>
hasFailedState && <span className={styles.error}>{t('failed')}</span>
)}
</div>
);
+57 -52
View File
@@ -2,14 +2,13 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Comment, useAuthorAvatar, useEditedComment, useReplies, useAccount, usePublishCommentModeration } from '@plebbit/plebbit-react-hooks';
import { Comment, useAuthorAvatar, useEditedComment, useReplies, useAccount, usePublishCommentModeration, useAccountComment } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js';
import styles from '../../views/post/post.module.css';
import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { QUOTE_NUMBER_REGEX } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -34,8 +33,10 @@ import { capitalize, lowerCase } from 'lodash';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import usePostNumberStore from '../../stores/use-post-number-store';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
const { addChallenge } = useChallengesStore.getState();
@@ -167,15 +168,21 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
const stateString = useStateString(post);
const hasFailedState = state === 'failed';
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
const showUserID = pseudonymityMode !== 'per-reply';
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = useMemo(() => {
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
return 0;
}
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
}, [showUserID, deleted, removed, shortAddress, postCid]);
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
@@ -319,9 +326,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
) : (
<>
<span>No.</span>
<span className={styles.pendingCid}>
{state === 'failed' || stateString === 'Failed' ? capitalize(t('failed')) : state === 'pending' ? capitalize(t('pending')) : ''}
</span>
<span className={styles.pendingCid}>{hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))}</span>
</>
)}
{shouldShowPendingApprovalButtons && (
@@ -382,7 +387,15 @@ const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
const ReplyBacklinks = ({ post, quotedByMap }: PostProps) => {
const { cid, parentCid } = post || {};
const { replies } = useReplies({ comment: post, flat: true, accountComments: { newerThan: Infinity } });
const repliesResult = useReplies({
comment: post,
sortType: 'old',
flat: true,
accountComments: { newerThan: Infinity, append: true },
});
const { replies } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
const opBacklinks =
cid &&
@@ -395,9 +408,9 @@ const ReplyBacklinks = ({ post, quotedByMap }: PostProps) => {
)
.filter(Boolean);
const replyBacklinks = cid && parentCid && ((replies?.length || 0) > 0 || quotedByMap?.get(cid)?.length) && (
const replyBacklinks = cid && parentCid && ((repliesForRender?.length || 0) > 0 || quotedByMap?.get(cid)?.length) && (
<>
{replies?.map(
{repliesForRender?.map(
(reply: Comment, index: number) =>
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
)}
@@ -421,9 +434,13 @@ const ReplyBacklinks = ({ post, quotedByMap }: PostProps) => {
};
const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: PostProps) => {
let post = reply;
const accountReply = useAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
const hasReplyIndex = typeof reply?.index === 'number';
let post = hasReplyIndex && accountReply?.index === reply.index ? accountReply : reply;
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment: reply });
const { editedComment } = useEditedComment({ comment: post });
if (editedComment) {
post = editedComment;
}
@@ -476,12 +493,28 @@ const PostMobile = ({
const directories = useDirectories();
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined;
const linksCount = useCountLinksInReplies(post);
const { replies, hasMore, loadMore } = useReplies({ comment: post, accountComments: { newerThan: Infinity } });
const repliesResult = useReplies({
comment: post,
sortType: 'old',
flat: true,
accountComments: { newerThan: Infinity, append: true },
});
const { replies, hasMore, loadMore } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
const reset = (repliesResult as { reset?: () => Promise<void> }).reset;
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
useEffect(() => {
if ((isInPostView || isInPendingPostView) && reset) {
setResetFunction(() => {
reset();
});
}
}, [isInPostView, isInPendingPostView, reset, setResetFunction]);
const registerComments = usePostNumberStore((s) => s.registerComments);
const numberToCid = usePostNumberStore((s) => s.numberToCid);
const prevCidsRef = useRef<string>('');
useEffect(() => {
const all = post ? [post, ...(replies || [])] : replies || [];
const all = post ? [post, ...repliesForRender] : repliesForRender;
if (!all.length) return;
const cidsKey = all
.map((c) => c?.cid)
@@ -491,46 +524,18 @@ const PostMobile = ({
if (cidsKey === prevCidsRef.current) return;
prevCidsRef.current = cidsKey;
registerComments(all);
}, [post, replies, registerComments]);
}, [post, repliesForRender, registerComments]);
const isInPostPageView = isPostPageView(location.pathname, params);
const { hidden, unhide } = useHide({ cid });
const stateString = useStateString(post) || t('loading_post');
const hasFailedState = state === 'failed';
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
const filteredReplies = useMemo(() => (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]);
const filteredReplies = useMemo(() => repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [repliesForRender]);
// Compute quotedByMap: map each quoted CID to array of replies that quote it
const quotedByMap = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const cidSet = new Set<string>();
if (reply.quotedCids?.length) {
for (const quotedCid of reply.quotedCids) {
cidSet.add(quotedCid);
}
}
if (reply.content) {
for (const match of reply.content.matchAll(QUOTE_NUMBER_REGEX)) {
const postNumber = parseInt(match[1], 10);
const quotedCid = numberToCid[postNumber];
if (quotedCid) {
cidSet.add(quotedCid);
}
}
}
for (const quotedCid of cidSet) {
const arr = map.get(quotedCid);
if (arr) arr.push(reply);
else map.set(quotedCid, [reply]);
}
}
return map;
}, [filteredReplies, numberToCid]);
const quotedByMap = useQuotedByMap(filteredReplies);
// Virtuoso scroll position management for infinite replies
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
@@ -674,7 +679,7 @@ const PostMobile = ({
{!(pinned && !isInPostView) &&
!showAllReplies &&
!isInPendingPostView &&
replies &&
repliesForRender &&
showReplies &&
filteredReplies.slice(-5).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
@@ -682,12 +687,12 @@ const PostMobile = ({
</div>
))}
</div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
{!isInPendingPostView && stateString && !hasFailedState && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<div className={styles.stateString}>
<LoadingEllipsis string={stateString} />
</div>
) : (
state === 'failed' && <span className={styles.error}>{t('failed')}</span>
hasFailedState && <span className={styles.error}>{t('failed')}</span>
)}
</div>
)}
@@ -15,6 +15,7 @@ interface ReplyQuotePreviewProps {
isQuotelinkReply?: boolean;
quotelinkReply?: Comment;
isOP?: boolean;
showTrailingBreak?: boolean;
}
const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => {
@@ -70,7 +71,7 @@ const scrollToThreadCardTop = (threadCid: string) => {
return true;
};
const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP }: ReplyQuotePreviewProps) => {
const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak = true }: ReplyQuotePreviewProps) => {
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
const [outOfViewCid, setOutOfViewCid] = useState<string | null>(null);
const placementRef = useRef<Placement>('right');
@@ -194,7 +195,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
{isOP && ' (OP)'}
{quotelinkReply?.author?.address === account?.author?.address && ' (You)'}
</Link>
<br />
{showTrailingBreak && <br />}
{hoveredCid === quotelinkReply?.cid &&
outOfViewCid === quotelinkReply?.cid &&
createPortal(
@@ -339,7 +340,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink;
};
const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP }: ReplyQuotePreviewProps) => {
const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak }: ReplyQuotePreviewProps) => {
const isMobile = useIsMobile();
return isMobile ? (
@@ -351,6 +352,7 @@ const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQ
isBacklinkReply={isBacklinkReply}
isQuotelinkReply={isQuotelinkReply}
isOP={isOP}
showTrailingBreak={showTrailingBreak}
/>
);
};
@@ -22,21 +22,21 @@ const CheckForUpdates = () => {
const checkForUpdates = async () => {
try {
setLoading(true);
const packageRes = await fetch('https://raw.githubusercontent.com/plebbit/5chan/master/package.json', { cache: 'no-cache' });
const packageRes = await fetch('https://raw.githubusercontent.com/bitsocialhq/5chan/master/package.json', { cache: 'no-cache' });
const packageData = await packageRes.json();
let updateAvailable = false;
if (packageJson.version !== packageData.version) {
const newVersionText = t('new_stable_version', { newVersion: packageData.version, oldVersion: packageJson.version });
const updateActionText = isElectron
? t('download_latest_desktop', { link: 'https://github.com/plebbit/5chan/releases/latest', interpolation: { escapeValue: false } })
? t('download_latest_desktop', { link: 'https://github.com/bitsocialhq/5chan/releases/latest', interpolation: { escapeValue: false } })
: t('refresh_to_update');
alert(newVersionText + ' ' + updateActionText);
updateAvailable = true;
}
if (commitRef && commitRef.length > 0) {
const commitRes = await fetch('https://api.github.com/repos/plebbit/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
const commitRes = await fetch('https://api.github.com/repos/bitsocialhq/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
const commitData = await commitRes.json();
const latestCommitHash = commitData[0].sha;
@@ -138,13 +138,20 @@
.editSubscriptionsLink {
font-size: var(--body-font-size);
color: var(--link-color, #0000ee);
text-decoration: underline;
color: var(--post-link-text-color);
text-decoration: var(--post-content-link-text-decoration);
cursor: pointer;
}
.editSubscriptionsWrapper {
display: inline-flex;
align-items: center;
gap: 0;
}
.editSubscriptionsLink:hover {
color: var(--link-hover-color, #551a8b);
color: var(--post-link-text-color-hover);
text-decoration: var(--post-content-link-text-decoration-hover);
}
.topbarEditFooter {
@@ -8,7 +8,7 @@ import styles from './topbar-edit-modal.module.css';
const TopbarEditModal = () => {
const { showModal, closeTopbarEditModal } = useTopbarEditModalStore();
const { visibleDirectories, visibleSubscriptions, setDirectoryVisibility, setSubscriptionVisibility } = useTopbarVisibilityStore();
const { visibleDirectories, showSubscriptionsInTopbar, setDirectoryVisibility, setShowSubscriptionsInTopbar } = useTopbarVisibilityStore();
const account = useAccount();
const subscriptions = useMemo(() => account?.subscriptions || [], [account?.subscriptions]);
const location = useLocation();
@@ -37,17 +37,16 @@ const TopbarEditModal = () => {
// Local state for text input (will be saved on Save click)
// Empty string means all directories visible (default), otherwise show only the specified codes
const [localDirectoryInput, setLocalDirectoryInput] = useState<string>(allDirectoriesVisible ? '' : directoriesToString(visibleDirectories));
// Check if all subscriptions are visible (if any subscription is visible, show subscriptions is checked)
const [showSubscriptions, setShowSubscriptions] = useState<boolean>(subscriptions.length > 0 && subscriptions.some((addr: string) => visibleSubscriptions.has(addr)));
const [showSubscriptions, setShowSubscriptions] = useState<boolean>(showSubscriptionsInTopbar);
// Update local state when modal opens or store changes
// Sync local state when modal opens or store changes
useEffect(() => {
if (showModal) {
const allVisible = allBoardCodes.every((code) => visibleDirectories.has(code));
setLocalDirectoryInput(allVisible ? '' : directoriesToString(visibleDirectories));
setShowSubscriptions(subscriptions.length > 0 && subscriptions.some((addr: string) => visibleSubscriptions.has(addr)));
setShowSubscriptions(showSubscriptionsInTopbar);
}
}, [showModal, visibleDirectories, visibleSubscriptions, allBoardCodes, subscriptions]);
}, [showModal, visibleDirectories, showSubscriptionsInTopbar, allBoardCodes]);
if (!showModal) {
return null;
@@ -75,13 +74,7 @@ const TopbarEditModal = () => {
});
}
// Apply subscription visibility changes
// If showSubscriptions is checked, make all subscriptions visible
// Otherwise, hide all subscriptions
subscriptions.forEach((address: string) => {
setSubscriptionVisibility(address, showSubscriptions);
});
setShowSubscriptionsInTopbar(showSubscriptions);
closeTopbarEditModal();
};
@@ -107,8 +100,9 @@ const TopbarEditModal = () => {
<div className={styles.section}>
<div className={styles.checkboxItem}>
<input type='checkbox' id='show-subscriptions' checked={showSubscriptions} onChange={(e) => setShowSubscriptions(e.target.checked)} />
<label htmlFor='show-subscriptions'>
show subscriptions (
<label htmlFor='show-subscriptions'>show subscriptions</label>
<span className={styles.editSubscriptionsWrapper}>
(
<Link
to={location.pathname.replace(/\/$/, '') + '/settings#subscriptions-settings'}
className={styles.editSubscriptionsLink}
@@ -120,7 +114,7 @@ const TopbarEditModal = () => {
edit subscriptions
</Link>
)
</label>
</span>
</div>
</div>
)}
+3 -3
View File
@@ -96,7 +96,7 @@ const TopBarDesktop = () => {
const { openCreateBoardModal } = useCreateBoardModalStore();
const { openTopbarEditModal } = useTopbarEditModalStore();
const { openDirectoryModal } = useDirectoryModalStore();
const { visibleDirectories, visibleSubscriptions } = useTopbarVisibilityStore();
const { visibleDirectories, showSubscriptionsInTopbar } = useTopbarVisibilityStore();
const directories = useDirectories();
// Memoize allBoardCodes since it's derived from a constant
@@ -127,8 +127,8 @@ const TopBarDesktop = () => {
},
);
// Filter subscriptions to only show visible ones
const visibleSubscriptionAddresses = subscriptions.filter((address: string) => visibleSubscriptions.has(address));
// Show all subscriptions when enabled; no separate per-address tracking (avoids drift when subscribing from board-buttons)
const visibleSubscriptionAddresses = showSubscriptionsInTopbar ? subscriptions : [];
// Check if any directories are hidden
const hasHiddenDirectories = useMemo(() => {
+1 -1
View File
@@ -10,7 +10,7 @@ const Version = () => {
return (
<>
<a
href={commitRef ? `https://github.com/plebbit/5chan/commit/${commitRef}` : `https://github.com/plebbit/5chan/releases/tag/v${version}`}
href={commitRef ? `https://github.com/bitsocialhq/5chan/commit/${commitRef}` : `https://github.com/bitsocialhq/5chan/releases/tag/v${version}`}
target='_blank'
rel='noopener noreferrer'
>
+1 -1
View File
@@ -28,7 +28,7 @@ export interface DirectoriesState {
error: Error | null;
}
const GITHUB_URL = 'https://raw.githubusercontent.com/plebbit/lists/master/5chan-directories.json';
const GITHUB_URL = 'https://raw.githubusercontent.com/bitsocialhq/lists/master/5chan-directories.json';
const LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useMemo, useRef } from 'react';
import { Comment } from '@plebbit/plebbit-react-hooks';
import { QUOTE_NUMBER_REGEX } from '../lib/utils/url-utils';
import usePostNumberStore from '../stores/use-post-number-store';
interface ReplyQuoteTargets {
reply: Comment;
quotedPostNumbers: number[];
}
const getReplyFingerprint = (reply: Comment) =>
`${reply?.cid ?? ''}|${reply?.deleted ? '1' : '0'}|${reply?.removed ? '1' : '0'}|${reply?.edit?.timestamp ?? ''}|${reply?.state ?? ''}`;
const areQuotedByMapsEquivalent = (previousMap: Map<string, Comment[]>, nextMap: Map<string, Comment[]>) => {
if (previousMap.size !== nextMap.size) {
return false;
}
for (const [quotedCid, nextReplies] of nextMap) {
const previousReplies = previousMap.get(quotedCid);
if (!previousReplies || previousReplies.length !== nextReplies.length) {
return false;
}
for (let i = 0; i < nextReplies.length; i++) {
if (getReplyFingerprint(previousReplies[i]) !== getReplyFingerprint(nextReplies[i])) {
return false;
}
}
}
return true;
};
const extractReplyQuoteTargets = (replies: Comment[]) => {
const quotedPostNumbers = new Set<number>();
const replyQuoteTargets: ReplyQuoteTargets[] = [];
for (const reply of replies) {
const quotedPostNumbersForReply: number[] = [];
if (reply.content) {
for (const match of reply.content.matchAll(QUOTE_NUMBER_REGEX)) {
const postNumber = Number.parseInt(match[1], 10);
if (!Number.isNaN(postNumber)) {
quotedPostNumbersForReply.push(postNumber);
quotedPostNumbers.add(postNumber);
}
}
}
replyQuoteTargets.push({
reply,
quotedPostNumbers: quotedPostNumbersForReply,
});
}
return {
replyQuoteTargets,
quotedPostNumbers: [...quotedPostNumbers].sort((a, b) => a - b),
};
};
const useQuotedByMap = (replies: Comment[] = []) => {
const stableQuotedByMapRef = useRef<Map<string, Comment[]>>(new Map());
const { replyQuoteTargets, quotedPostNumbers } = useMemo(() => extractReplyQuoteTargets(replies), [replies]);
// Subscribe only to post numbers referenced in this thread to avoid unrelated global store churn.
const quotedNumbersSignature = usePostNumberStore(
useCallback((state) => quotedPostNumbers.map((postNumber) => `${postNumber}:${state.numberToCid[postNumber] ?? ''}`).join('|'), [quotedPostNumbers]),
);
const quotedNumberToCid = useMemo(() => {
if (quotedPostNumbers.length === 0) {
return {} as Record<number, string>;
}
const { numberToCid } = usePostNumberStore.getState();
const nextQuotedNumberToCid: Record<number, string> = {};
for (const postNumber of quotedPostNumbers) {
const quotedCid = numberToCid[postNumber];
if (quotedCid) {
nextQuotedNumberToCid[postNumber] = quotedCid;
}
}
return nextQuotedNumberToCid;
}, [quotedPostNumbers, quotedNumbersSignature]);
return useMemo(() => {
const map = new Map<string, Comment[]>();
for (const { reply, quotedPostNumbers: quotedNumbersForReply } of replyQuoteTargets) {
const cidSet = new Set<string>();
if (reply.quotedCids?.length) {
for (const quotedCid of reply.quotedCids) {
cidSet.add(quotedCid);
}
}
for (const postNumber of quotedNumbersForReply) {
const quotedCid = quotedNumberToCid[postNumber];
if (quotedCid) {
cidSet.add(quotedCid);
}
}
for (const quotedCid of cidSet) {
const repliesQuotingCid = map.get(quotedCid);
if (repliesQuotingCid) {
repliesQuotingCid.push(reply);
} else {
map.set(quotedCid, [reply]);
}
}
}
if (areQuotedByMapsEquivalent(stableQuotedByMapRef.current, map)) {
return stableQuotedByMapRef.current;
}
stableQuotedByMapRef.current = map;
return map;
}, [replyQuoteTargets, quotedNumberToCid]);
};
export default useQuotedByMap;
+13 -3
View File
@@ -14,18 +14,28 @@ const usePostNumberStore = create<PostNumberState>((set) => ({
if (!comments?.length) return;
set((state) => {
const nextNumberToCid = { ...state.numberToCid };
const nextCidToNumber = { ...state.cidToNumber };
let nextNumberToCid = state.numberToCid;
let nextCidToNumber = state.cidToNumber;
let hasUpdates = false;
for (const c of comments) {
const num = c?.number;
const cid = c?.cid;
if (typeof num === 'number' && cid) {
if (typeof num === 'number' && cid && (nextNumberToCid[num] !== cid || nextCidToNumber[cid] !== num)) {
if (!hasUpdates) {
nextNumberToCid = { ...state.numberToCid };
nextCidToNumber = { ...state.cidToNumber };
hasUpdates = true;
}
nextNumberToCid[num] = cid;
nextCidToNumber[cid] = num;
}
}
if (!hasUpdates) {
return state;
}
return { numberToCid: nextNumberToCid, cidToNumber: nextCidToNumber };
});
},
+31 -33
View File
@@ -7,13 +7,12 @@ const LOCALSTORAGE_KEY_SUBSCRIPTIONS = '5chan-topbar-subscriptions-visible';
interface TopbarVisibilityState {
// Directory codes that are visible (all visible by default)
visibleDirectories: Set<string>;
// Subscription addresses that are visible in topbar (all hidden by default)
visibleSubscriptions: Set<string>;
// If true, show all account subscriptions in topbar (default: false)
showSubscriptionsInTopbar: boolean;
// Actions
toggleDirectory: (code: string) => void;
toggleSubscription: (address: string) => void;
setDirectoryVisibility: (code: string, visible: boolean) => void;
setSubscriptionVisibility: (address: string, visible: boolean) => void;
setShowSubscriptionsInTopbar: (show: boolean) => void;
// Initialize from localStorage
initialize: () => void;
}
@@ -40,15 +39,36 @@ const saveToLocalStorage = (key: string, set: Set<string>) => {
}
};
const loadShowSubscriptionsFromStorage = (): boolean => {
try {
const stored = localStorage.getItem(LOCALSTORAGE_KEY_SUBSCRIPTIONS);
if (!stored) return false;
const parsed = JSON.parse(stored);
// Migrate from old format (array of addresses)
if (Array.isArray(parsed)) return parsed.length > 0;
if (typeof parsed === 'boolean') return parsed;
} catch (e) {
console.warn('Failed to load subscriptions visibility from localStorage:', e);
}
return false;
};
const saveShowSubscriptionsToStorage = (show: boolean) => {
try {
localStorage.setItem(LOCALSTORAGE_KEY_SUBSCRIPTIONS, JSON.stringify(show));
} catch (e) {
console.warn('Failed to save subscriptions visibility to localStorage:', e);
}
};
const useTopbarVisibilityStore = create<TopbarVisibilityState>((set, _get) => {
// Initialize with all directories visible by default
const allBoardCodes = getAllBoardCodes();
const defaultVisibleDirectories = new Set(allBoardCodes);
const defaultVisibleSubscriptions = new Set<string>();
return {
visibleDirectories: loadFromLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, defaultVisibleDirectories),
visibleSubscriptions: loadFromLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, defaultVisibleSubscriptions),
showSubscriptionsInTopbar: loadShowSubscriptionsFromStorage(),
toggleDirectory: (code: string) => {
set((state) => {
@@ -63,19 +83,6 @@ const useTopbarVisibilityStore = create<TopbarVisibilityState>((set, _get) => {
});
},
toggleSubscription: (address: string) => {
set((state) => {
const newSet = new Set(state.visibleSubscriptions);
if (newSet.has(address)) {
newSet.delete(address);
} else {
newSet.add(address);
}
saveToLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, newSet);
return { visibleSubscriptions: newSet };
});
},
setDirectoryVisibility: (code: string, visible: boolean) => {
set((state) => {
const newSet = new Set(state.visibleDirectories);
@@ -89,26 +96,17 @@ const useTopbarVisibilityStore = create<TopbarVisibilityState>((set, _get) => {
});
},
setSubscriptionVisibility: (address: string, visible: boolean) => {
set((state) => {
const newSet = new Set(state.visibleSubscriptions);
if (visible) {
newSet.add(address);
} else {
newSet.delete(address);
}
saveToLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, newSet);
return { visibleSubscriptions: newSet };
});
setShowSubscriptionsInTopbar: (show: boolean) => {
saveShowSubscriptionsToStorage(show);
set({ showSubscriptionsInTopbar: show });
},
initialize: () => {
// Load from localStorage on initialization
const directories = loadFromLocalStorage(LOCALSTORAGE_KEY_DIRECTORIES, defaultVisibleDirectories);
const subscriptions = loadFromLocalStorage(LOCALSTORAGE_KEY_SUBSCRIPTIONS, defaultVisibleSubscriptions);
const showSubscriptions = loadShowSubscriptionsFromStorage();
set({
visibleDirectories: directories,
visibleSubscriptions: subscriptions,
showSubscriptionsInTopbar: showSubscriptions,
});
},
};
+2 -2
View File
@@ -132,8 +132,8 @@ const FAQ = () => {
</dd>
<dt id='uploadimage'>Can I upload an image?</dt>
<dd>
Yes, but only in the Android app, which you can download on <a href='https://github.com/plebbit/5chan/releases/latest'>GitHub</a>. The app is able to
automatically upload media to image hosting services, like Imgur or catbox.moe, sharing your IP address with the image hosting service. This is not
Yes, but only in the Android app, which you can download on <a href='https://github.com/bitsocialhq/5chan/releases/latest'>GitHub</a>. The app is able
to automatically upload media to image hosting services, like Imgur or catbox.moe, sharing your IP address with the image hosting service. This is not
possible in the browser, which can't make backend requests.
<br />
<br />
+5 -5
View File
@@ -14,7 +14,7 @@ import DirectoryModal from '../../components/directory-modal';
import { getBoardPath } from '../../lib/utils/route-utils';
import { lowerCase } from 'lodash';
// https://github.com/plebbit/lists/blob/master/5chan-directories.json
// https://github.com/bitsocialhq/lists/blob/master/5chan-directories.json
const SearchBar = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -126,7 +126,7 @@ export const Footer = () => {
<>
<ul className={styles.footer}>
<li>
<a href='https://github.com/plebbit/5chan/blob/master/README.md' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/5chan/blob/master/README.md' target='_blank' rel='noopener noreferrer'>
{t('about')}
</a>
</li>
@@ -147,7 +147,7 @@ export const Footer = () => {
</a>
</li>
<li>
<a href='https://github.com/plebbit/5chan' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/5chan' target='_blank' rel='noopener noreferrer'>
GitHub
</a>
</li>
@@ -165,11 +165,11 @@ export const Footer = () => {
<div className={styles.footerInfo}>
<br />
<Version /> {' '}
<a href='https://github.com/plebbit/5chan/issues/new' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/5chan/issues/new' target='_blank' rel='noopener noreferrer'>
Feedback
</a>{' '}
{' '}
<a href='https://github.com/plebbit/5chan/graphs/contributors' target='_blank' rel='noopener noreferrer'>
<a href='https://github.com/bitsocialhq/5chan/graphs/contributors' target='_blank' rel='noopener noreferrer'>
Contact
</a>
<br />