mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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": "عنوان عقدة الند للند لهذه اللوحة"
|
||||
}
|
||||
|
||||
@@ -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": "এই বোর্ডের পিয়ার-টু-পিয়ার নোডের ঠিকানা"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 αυτού του πίνακα"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "آدرس گره همتا-همتای این تخته"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 של הלוח הזה"
|
||||
}
|
||||
|
||||
@@ -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": "इस बोर्ड के पीयर-टू-पीयर नोड का पता"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "この板のピアツーピアノードのアドレス"
|
||||
}
|
||||
|
||||
@@ -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 노드 주소"
|
||||
}
|
||||
|
||||
@@ -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": "या बोर्डच्या पीअर-टू-पीअर नोडचा पत्ता"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Адрес пирингового узла этой доски"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "ఈ బోర్డ్ యొక్క పీర్-టు-పీర్ నోడ్ యొక్క చిరునామా"
|
||||
}
|
||||
|
||||
@@ -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 ของบอร์ดนี้"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Адреса пірінгового вузла цієї дошки"
|
||||
}
|
||||
|
||||
@@ -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": "اس بورڈ کے پیر ٹو پیر نوڈ کا پتہ"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 "[Subscribe]" button, which adds it to their top bar. You can gain
|
||||
subscribers through direct links, word of mouth, or search—no 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'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 "[Subscribe]" button—
|
||||
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like "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's title, address, and NSFW status. Requirements: 99% uptime (they're P2P nodes), active, well-moderated, and relevant to the
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user