Merge pull request #327 from plebbit/development

Development
This commit is contained in:
Tom (plebeius.eth)
2024-03-27 14:13:12 +01:00
committed by GitHub
70 changed files with 2347 additions and 1500 deletions
+2 -2
View File
@@ -7,7 +7,7 @@
"dependencies": {
"@capacitor/app": "1.1.1",
"@floating-ui/react": "0.26.1",
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#40dfddc6982c85cf07fdfc499cd3787265bc6fe5",
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#63833e978ca25f9846544d32b9c67810af0daf19",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "13.0.0",
"@testing-library/user-event": "13.2.1",
@@ -43,7 +43,7 @@
"scripts": {
"start": "react-scripts start",
"build": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false react-scripts build",
"build-netlify": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true REACT_APP_COMMIT_REF=$COMMIT_REF react-scripts build",
"build-netlify": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true REACT_APP_COMMIT_REF=$COMMIT_REF CI='' react-scripts build",
"test": "react-scripts test",
"analyze-bundle": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true react-scripts build && source-map-explorer 'build/static/js/*.js'",
"eject": "react-scripts eject",
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "فهرس",
"all": "الكل",
"subscriptions": "الاشتراكات",
"board": "منتدى"
"board": "منتدى",
"link_to_post": "الرابط لهذه الرسالة",
"reply_to_post": "الرد على هذه المشاركة",
"sticky": "مثبت",
"closed": "مغلق",
"reply": "رد",
"link": "رابط"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "ক্যাটালগ",
"all": "সব",
"subscriptions": "সাবস্ক্রিপশন",
"board": "বোর্ড"
"board": "বোর্ড",
"link_to_post": "এই পোস্টে লিংক",
"reply_to_post": "এই পোস্টে প্রতিক্রিয়া",
"sticky": "স্টিকি",
"closed": "বন্ধ",
"reply": "উত্তর",
"link": "লিংক"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Vše",
"subscriptions": "Předplatné",
"board": "Fórum"
"board": "Fórum",
"link_to_post": "Odkaz na tento příspěvek",
"reply_to_post": "Odpovědět na tento příspěvek",
"sticky": "Připnutý",
"closed": "Zavřeno",
"reply": "Odpovědět",
"link": "Odkaz"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Alle",
"subscriptions": "Abonnementer",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link til dette indlæg",
"reply_to_post": "Svar på dette indlæg",
"sticky": "Fikseret",
"closed": "Lukket",
"reply": "Svar",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Alle",
"subscriptions": "Abonnements",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link zu diesem Beitrag",
"reply_to_post": "Antworten auf diesen Beitrag",
"sticky": "Angeheftet",
"closed": "Geschlossen",
"reply": "Antworten",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Κατάλογος",
"all": "Όλα",
"subscriptions": "Συνδρομές",
"board": "Φόρουμ"
"board": "Φόρουμ",
"link_to_post": "Σύνδεσμος για αυτήν την ανάρτηση",
"reply_to_post": "Απάντηση σε αυτήν την ανάρτηση",
"sticky": "Κολλημένος",
"closed": "Κλειστό",
"reply": "Απάντηση",
"link": "Σύνδεσμος"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalog",
"all": "All",
"subscriptions": "Subscriptions",
"board": "Board"
"board": "Board",
"link_to_post": "Link to this post",
"reply_to_post": "Reply to this post",
"sticky": "Sticky",
"closed": "Closed",
"reply": "Reply",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catálogo",
"all": "Todo",
"subscriptions": "Suscripciones",
"board": "Foro"
"board": "Foro",
"link_to_post": "Enlace a esta publicación",
"reply_to_post": "Responder a esta publicación",
"sticky": "Fijado",
"closed": "Cerrado",
"reply": "Responder",
"link": "Enlace"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "کاتالوگ",
"all": "همه",
"subscriptions": "اشتراک‌ها",
"board": "تالار"
"board": "تالار",
"link_to_post": "پیوند به این پست",
"reply_to_post": "پاسخ به این پست",
"sticky": "چسبیده",
"closed": "بسته",
"reply": "پاسخ",
"link": "لینک"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Luettelo",
"all": "Kaikki",
"subscriptions": "Tilaukset",
"board": "Foorumi"
"board": "Foorumi",
"link_to_post": "Linkki tähän viestiin",
"reply_to_post": "Vastaa tähän viestiin",
"sticky": "Tikki",
"closed": "Suljettu",
"reply": "Vastaa",
"link": "Linkki"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalogo",
"all": "Lahat",
"subscriptions": "Mga Subscription",
"board": "Board"
"board": "Board",
"link_to_post": "Link sa post na ito",
"reply_to_post": "Tumugon sa post na ito",
"sticky": "Malagkit",
"closed": "Sarado",
"reply": "Tumugon",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalogue",
"all": "Tous",
"subscriptions": "Abonnements",
"board": "Forum"
"board": "Forum",
"link_to_post": "Lien vers ce message",
"reply_to_post": "Répondre à ce message",
"sticky": "Épinglé",
"closed": "Fermé",
"reply": "Répondre",
"link": "Lien"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "קטלוג",
"all": "הכל",
"subscriptions": "מנויים",
"board": "פורום"
"board": "פורום",
"link_to_post": "קישור להודעה זו",
"reply_to_post": "הגבה להודעה זו",
"sticky": "מדבקה",
"closed": "סגור",
"reply": "הגב",
"link": "קישור"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "कैटलॉग",
"all": "सभी",
"subscriptions": "सदस्यता",
"board": "फोरम"
"board": "फोरम",
"link_to_post": "इस पोस्ट का लिंक",
"reply_to_post": "इस पोस्ट का जवाब दें",
"sticky": "चिपकाया",
"closed": "बंद",
"reply": "जवाब दें",
"link": "लिंक"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalógus",
"all": "Összes",
"subscriptions": "Előfizetések",
"board": "Fórum"
"board": "Fórum",
"link_to_post": "Hivatkozás erre a hozzászólásra",
"reply_to_post": "Válasz erre a hozzászólásra",
"sticky": "Rögzített",
"closed": "Zárt",
"reply": "Válaszolás",
"link": "Hivatkozás"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Semua",
"subscriptions": "Langganan",
"board": "Forum"
"board": "Forum",
"link_to_post": "Tautan ke pos ini",
"reply_to_post": "Balas ke pos ini",
"sticky": "Menempel",
"closed": "Tutup",
"reply": "Balas",
"link": "Tautan"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalogo",
"all": "Tutti",
"subscriptions": "Iscrizioni",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link per questo post",
"reply_to_post": "Commenta questo post",
"sticky": "Incollato",
"closed": "Chiuso",
"reply": "Commenta",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "カタログ",
"all": "すべて",
"subscriptions": "購読",
"board": "掲示板"
"board": "掲示板",
"link_to_post": "この投稿へのリンク",
"reply_to_post": "この投稿に返信",
"sticky": "固定",
"closed": "閉鎖",
"reply": "返信",
"link": "リンク"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "카탈로그",
"all": "모두",
"subscriptions": "구독",
"board": "게시판"
"board": "게시판",
"link_to_post": "이 게시물에 대한 링크",
"reply_to_post": "이 게시물에 답장하기",
"sticky": "고정된",
"closed": "닫힘",
"reply": "답장",
"link": "링크"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "कॅटलॉग",
"all": "सर्व",
"subscriptions": "सदस्यता",
"board": "फोरम"
"board": "फोरम",
"link_to_post": "ह्या पोस्टचा लिंक",
"reply_to_post": "या पोस्टवर प्रतिसाद द्या",
"sticky": "स्टिकी",
"closed": "बंद",
"reply": "प्रतिसाद",
"link": "लिंक"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalogus",
"all": "Alles",
"subscriptions": "Abonnementen",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link naar dit bericht",
"reply_to_post": "Reageren op dit bericht",
"sticky": "Vastgemaakt",
"closed": "Gesloten",
"reply": "Antwoord",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Alle",
"subscriptions": "Abonnementer",
"board": "Forum"
"board": "Forum",
"link_to_post": "Lenke til dette innlegget",
"reply_to_post": "Svar på dette innlegget",
"sticky": "Klistret",
"closed": "Stengt",
"reply": "Svar",
"link": "Lenke"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Wszystkie",
"subscriptions": "Subskrypcje",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link do tego posta",
"reply_to_post": "Odpowiedz na ten post",
"sticky": "Przyklejony",
"closed": "Zamknięty",
"reply": "Odpowiedz",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catálogo",
"all": "Todos",
"subscriptions": "Inscrições",
"board": "Fórum"
"board": "Fórum",
"link_to_post": "Link para esta postagem",
"reply_to_post": "Responder a esta postagem",
"sticky": "Fixado",
"closed": "Fechado",
"reply": "Responder",
"link": "Link"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalog",
"all": "Toate",
"subscriptions": "Abonamente",
"board": "Forum"
"board": "Forum",
"link_to_post": "Link către acest mesaj",
"reply_to_post": "Răspunde la acest mesaj",
"sticky": "Lipit",
"closed": "Închis",
"reply": "Răspunde",
"link": "Legătură"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Каталог",
"all": "Все",
"subscriptions": "Подписки",
"board": "Форум"
"board": "Форум",
"link_to_post": "Ссылка на это сообщение",
"reply_to_post": "Ответить на это сообщение",
"sticky": "Прикреплено",
"closed": "Закрыто",
"reply": "Ответить",
"link": "Ссылка"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Të gjitha",
"subscriptions": "Abonimet",
"board": "Forumi"
"board": "Forumi",
"link_to_post": "Link për këtë postim",
"reply_to_post": "Përgjigju në këtë postim",
"sticky": "Ngjitës",
"closed": "Mbyllur",
"reply": "Përgjigju",
"link": "Lidhje"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Allt",
"subscriptions": "Prenumerationer",
"board": "Forum"
"board": "Forum",
"link_to_post": "Länk till detta inlägg",
"reply_to_post": "Svara på detta inlägg",
"sticky": "Klistrad",
"closed": "Stängd",
"reply": "Svara",
"link": "Länk"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "కాటలాగ్",
"all": "అన్ని",
"subscriptions": "సబ్‌స్క్రిప్షన్‌లు",
"board": "ఫోరం"
"board": "ఫోరం",
"link_to_post": "ఈ పోస్టుకు లింక్",
"reply_to_post": "ఈ పోస్టుకు మరియుని జవాబు ఇవ్వండి",
"sticky": "స్టికీ",
"closed": "మూసివేయబడినది",
"reply": "స్పందించు",
"link": "లింక్"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "แคตตาล็อก",
"all": "ทั้งหมด",
"subscriptions": "การสมัครสมาชิก",
"board": "กระดาน"
"board": "กระดาน",
"link_to_post": "ลิงก์ไปยังโพสต์นี้",
"reply_to_post": "ตอบกลับไปยังโพสต์นี้",
"sticky": "ปักหมุด",
"closed": "ปิด",
"reply": "ตอบ",
"link": "ลิงก์"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Katalog",
"all": "Tümü",
"subscriptions": "Abonelikler",
"board": "Forum"
"board": "Forum",
"link_to_post": "Bu gönderiye bağlantı",
"reply_to_post": "Bu gönderiye yanıtla",
"sticky": "Yapışkan",
"closed": "Kapalı",
"reply": "Yanıtla",
"link": "Bağlantı"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Каталог",
"all": "Всі",
"subscriptions": "Підписки",
"board": "Форум"
"board": "Форум",
"link_to_post": "Посилання на цей допис",
"reply_to_post": "Відповісти на це повідомлення",
"sticky": "Прикріплений",
"closed": "Закрито",
"reply": "Відповісти",
"link": "Посилання"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "کیٹالوگ",
"all": "سب",
"subscriptions": "سبسکرپشنز",
"board": "پیغام بورڈ"
"board": "پیغام بورڈ",
"link_to_post": "اس پوسٹ کا لنک",
"reply_to_post": "اس پوسٹ کا جواب دیں",
"sticky": "چسپی",
"closed": "بند",
"reply": "جواب دیں",
"link": "لنک"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "Catalog",
"all": "Tất cả",
"subscriptions": "Đăng ký",
"board": "Diễn đàn"
"board": "Diễn đàn",
"link_to_post": "Liên kết đến bài viết này",
"reply_to_post": "Trả lời bài viết này",
"sticky": "Dán",
"closed": "Đóng",
"reply": "Trả lời",
"link": "Liên kết"
}
+7 -1
View File
@@ -29,5 +29,11 @@
"catalog": "目录",
"all": "所有",
"subscriptions": "订阅",
"board": "论坛"
"board": "论坛",
"link_to_post": "链接到此帖",
"reply_to_post": "回复此帖",
"sticky": "置顶",
"closed": "关闭",
"reply": "回复",
"link": "链接"
}
+6
View File
@@ -9,3 +9,9 @@
overflow-x: hidden;
min-height: 100vh;
}
@media (min-width: 640px) {
.app {
padding: 5px;
}
}
@@ -27,10 +27,6 @@
.content hr {
width: 90%;
/* revert to browser styling for burichan and futaba */
border: var(--hr-border, revert);
border-top: var(--hr-border-top, revert);
height: var(--hr-height, revert);
}
+4 -3
View File
@@ -17,14 +17,15 @@ interface BoardBannerProps {
address: string | undefined;
}
const BoardBanner = ({ title, address }: BoardBannerProps) => {
const BoardBanner = ({ address, title }: BoardBannerProps) => {
const displayAddress = address && address.length > 10 && !address.includes('.') ? address.slice(0, 13).concat('...') : address;
return (
<div className={styles.content}>
<div className={styles.bannerCnt}>
<ImageBanner key={address} />
</div>
<div className={styles.boardTitle}>{title || `p/${address}`}</div>
{title && <div className={styles.boardAddress}>p/{address}</div>}
<div className={styles.boardTitle}>{title || `p/${displayAddress}`}</div>
<div className={styles.boardAddress}>p/{address}</div>
<hr />
</div>
);
@@ -17,4 +17,8 @@
.mobileBoardButtons {
display: none;
}
.subscribeButton {
float: right;
}
}
@@ -40,7 +40,15 @@ export const MobileBoardButtons = ({ address }: BoardButtonsProps) => {
export const DesktopBoardButtons = ({ address }: BoardButtonsProps) => {
return (
<div className={styles.desktopBoardButtons}>
<SubscribeButton address={address} />
<hr />
[
<OptionsButton />
] [
<CatalogButton />]
<span className={styles.subscribeButton}>
[
<SubscribeButton address={address} />]
</span>
</div>
);
};
+8 -15
View File
@@ -1,18 +1,17 @@
.boardNavDesktop {
padding: 5px;
font-size: var(--boardnav-font-size);
color: var(--boardnav-separator-color);
}
.boardNavDesktop a {
color: var(--link-internal-text-color);
text-decoration: var(--link-internal-text-decoration);
color: var(--boardnav-desktop-link-text-color);
text-decoration: var(--boardnav-desktop-link-text-decoration);
}
.boardNavDesktop a:hover {
color: var(--link-internal-text-color-hover);
text-decoration: var(--link-internal-text-decoration-hover);
color: var(--boardnav-desktop-link-text-color-hover);
text-decoration: var(--boardnav-desktop-link-text-decoration-hover);
}
.navTopRight {
@@ -29,10 +28,6 @@
font-size: var(--boardnav-mobile-font-size);
}
.boardNavMobile .pageJump a {
color: var(--boardnav-mobile-button-text-color);
}
.boardNavMobile strong {
text-transform: capitalize;
padding-right: 5px;
@@ -54,13 +49,11 @@
padding: 3px 5px 0 0;
}
.pageJump span, .pageJump a {
.pageJump a, .mobileSearchButton {
padding-right: 5px;
}
.pageJump a {
color: var(--link-internal-text-color);
text-decoration: var(--link-internal-text-decoration);
color: var(--homepage-box-link-text-color);
text-decoration: var(--homepage-box-link-text-decoration);
text-transform: capitalize;
}
@media (max-width: 640px) {
+16 -8
View File
@@ -37,16 +37,24 @@ const BoardNavDesktop = ({ subplebbits }: BoardNavProps) => {
const BoardNavMobile = ({ subplebbits, currentSubplebbit }: BoardNavProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const displaySubplebbitAddress = currentSubplebbit && currentSubplebbit.length > 30 ? currentSubplebbit.slice(0, 30).concat('...') : currentSubplebbit;
const currentSubplebbitIsInList = subplebbits.some((subplebbit: any) => subplebbit.address === currentSubplebbit);
const boardSelect = (
<select value={currentSubplebbit} onChange={(e) => navigate(`/p/${e.target.value}`)}>
<option value='all'>All</option>
<option value='subscriptions'>Subscriptions</option>
{subplebbits.map((subplebbit: any, index: number) => (
<option key={index} value={subplebbit.address}>
{subplebbit.address.includes('.') ? subplebbit.address : subplebbit.title || subplebbit.address.slice(0, 10).concat('...')}
</option>
))}
<select value={currentSubplebbit || 'all'} onChange={(e) => navigate(`/p/${e.target.value}`)}>
{!currentSubplebbitIsInList && currentSubplebbit && <option value={currentSubplebbit}>{displaySubplebbitAddress}</option>}
<option value='all'>{t('all')}</option>
<option value='subscriptions'>{t('subscriptions')}</option>
{subplebbits.map((subplebbit: any, index: number) => {
const { address, title } = subplebbit;
const subplebbitAddress = address.includes('.') ? address : title || address.slice(0, 10).concat('...');
return (
<option key={index} value={subplebbit.address}>
{subplebbitAddress}
</option>
);
})}
</select>
);
@@ -2,10 +2,6 @@
min-width: 468px;
margin-left: -4px;
margin-bottom: 5px;
/* revert to browser styling for burichan and futaba */
border: var(--hr-border, revert);
border-top: var(--hr-border-top, revert);
height: var(--hr-height, revert);
}
.blotter {
+2 -2
View File
@@ -5,7 +5,7 @@ import { Trans, useTranslation } from 'react-i18next';
export interface BoardStatsProps {
address: string | undefined;
createdAt: number;
createdAt: number | undefined;
}
const BoardStats = ({ address, createdAt }: BoardStatsProps) => {
@@ -59,7 +59,7 @@ const BoardStats = ({ address, createdAt }: BoardStatsProps) => {
</tr>
<tr>
<td className={styles.lowercase}>
{unixToMMDDYYYY(createdAt)} {t('board_created')}
{createdAt && unixToMMDDYYYY(createdAt)} {t('board_created')}
{' / '}
<Trans
i18nKey='board_stats_all'
+70
View File
@@ -0,0 +1,70 @@
.audioEmbed {
height: 240px !important;
width: 700px !important;
}
@media (max-width: 768px) {
.audioEmbed {
max-height: 250px !important;
width: 100% !important;
}
}
.instagramEmbed {
height: 420px !important;
width: 360px !important;
}
@media (max-width: 768px) {
.instagramEmbed {
max-height: 420px !important;
width: 100% !important;
}
}
.redditEmbed {
width: 500px !important;
height: 520px !important;
}
@media (max-width: 768px) {
.redditEmbed {
width: 100% !important;
height: 520px !important;
}
}
.tiktokEmbed {
width: 400px !important;
height: 780px !important;
}
@media (max-width: 768px) {
.tiktokEmbed {
width: 100% !important;
height: 70vh !important;
}
}
.videoEmbed {
height: 450px !important;
width: 800px !important;
}
@media (max-width: 768px) {
.videoEmbed {
max-height: 250px !important;
width: 100% !important;
}
}
.xEmbed {
height: 580px !important;
}
@media (max-width: 768px) {
.xEmbed {
width: 100% !important;
height: 50vh !important;
}
}
+276
View File
@@ -0,0 +1,276 @@
import styles from './embed.module.css';
interface EmbedProps {
url: string;
}
const Embed = ({ url }: EmbedProps) => {
const parsedUrl = new URL(url);
if (youtubeHosts.has(parsedUrl.host) || parsedUrl.host.startsWith('yt.')) {
return <YoutubeEmbed parsedUrl={parsedUrl} />;
}
if (xHosts.has(parsedUrl.host)) {
return <XEmbed parsedUrl={parsedUrl} />;
}
if (redditHosts.has(parsedUrl.host)) {
return <RedditEmbed parsedUrl={parsedUrl} />;
}
if (twitchHosts.has(parsedUrl.host)) {
return <TwitchEmbed parsedUrl={parsedUrl} />;
}
if (tiktokHosts.has(parsedUrl.host)) {
return <TiktokEmbed parsedUrl={parsedUrl} />;
}
if (instagramHosts.has(parsedUrl.host)) {
return <InstagramEmbed parsedUrl={parsedUrl} />;
}
if (odyseeHosts.has(parsedUrl.host)) {
return <OdyseeEmbed parsedUrl={parsedUrl} />;
}
if (bitchuteHosts.has(parsedUrl.host)) {
return <BitchuteEmbed parsedUrl={parsedUrl} />;
}
if (streamableHosts.has(parsedUrl.host)) {
return <StreamableEmbed parsedUrl={parsedUrl} />;
}
if (spotifyHosts.has(parsedUrl.host)) {
return <SpotifyEmbed parsedUrl={parsedUrl} />;
}
};
interface EmbedComponentProps {
parsedUrl: URL;
}
const youtubeHosts = new Set<string>(['youtube.com', 'www.youtube.com', 'youtu.be', 'www.youtu.be', 'm.youtube.com']);
const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let youtubeId = parsedUrl.searchParams.get('v');
if (!youtubeId && parsedUrl.host.includes('youtu.be')) {
youtubeId = parsedUrl.pathname.substring(1);
}
if (youtubeId) {
return (
<iframe
className={styles.videoEmbed}
height='100%'
width='100%'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={`https://www.youtube-nocookie.com/embed/${youtubeId}`}
/>
);
}
return null;
};
const xHosts = new Set<string>(['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com']);
const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return (
<iframe
className={styles.xEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
title={parsedUrl.href}
srcDoc={`
<blockquote class="twitter-tweet" data-theme="dark">
<a href="${parsedUrl.href.replace('x.com', 'twitter.com')}"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
`}
/>
);
};
const redditHosts = new Set<string>(['reddit.com', 'www.reddit.com', 'old.reddit.com']);
const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
return (
<iframe
className={styles.redditEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
title={parsedUrl.href}
srcDoc={`
<style>
/* fix reddit iframe being centered */
iframe {
margin: 0!important;
}
</style>
<blockquote class="reddit-embed-bq" style="height:240px" data-embed-theme="dark">
<a href="${parsedUrl.href}"></a>
</blockquote>
<script async src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>
`}
/>
);
};
const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
let iframeUrl;
if (parsedUrl.pathname.startsWith('/videos/')) {
const videoId = parsedUrl.pathname.replace('/videos/', '');
iframeUrl = `https://player.twitch.tv/?video=${videoId}&parent=${window.location.hostname}`;
} else {
const channel = parsedUrl.pathname.replaceAll('/', '');
iframeUrl = `https://player.twitch.tv/?channel=${channel}&parent=${window.location.hostname}`;
}
return (
<iframe
className={styles.videoEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/.+\/video\//, '').replaceAll('/', '');
return (
<iframe
className={styles.tiktokEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
title={parsedUrl.href}
srcDoc={`
<blockquote class="tiktok-embed" data-video-id="${videoId}">
<a></a>
</blockquote>
<script async src="https://www.tiktok.com/embed.js"></script>
`}
/>
);
};
const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const pathNames = parsedUrl.pathname.replace(/\/+$/, '').split('/');
const id = pathNames[pathNames.length - 1];
return (
<iframe
className={styles.instagramEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
title={parsedUrl.href}
srcDoc={`
<blockquote class="instagram-media">
<a href="https://www.instagram.com/p/${id}/"></a>
</blockquote>
<script async src="//www.instagram.com/embed.js"></script>
`}
/>
);
};
const odyseeHosts = new Set<string>(['odysee.com', 'www.odysee.com']);
const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://odysee.com/$/embed${parsedUrl.pathname}`;
return (
<iframe
className={styles.videoEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replace(/\/video\//, '').replaceAll('/', '');
return (
<iframe
className={styles.videoEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={`https://www.bitchute.com/embed/${videoId}/`}
/>
);
};
const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const videoId = parsedUrl.pathname.replaceAll('/', '');
return (
<iframe
className={styles.videoEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={`https://streamable.com/e/${videoId}`}
/>
);
};
const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
const iframeUrl = `https://open.spotify.com/embed${parsedUrl.pathname}?theme=0`;
return (
<iframe
className={styles.audioEmbed}
height='100%'
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
title={parsedUrl.href}
src={iframeUrl}
/>
);
};
const canEmbedHosts = new Set<string>([
...youtubeHosts,
...xHosts,
...redditHosts,
...twitchHosts,
...tiktokHosts,
...instagramHosts,
...odyseeHosts,
...bitchuteHosts,
...streamableHosts,
...spotifyHosts,
]);
export const canEmbed = (parsedUrl: URL): boolean => canEmbedHosts.has(parsedUrl.host);
export default Embed;
+1
View File
@@ -0,0 +1 @@
export { default, canEmbed } from './embed';
+1
View File
@@ -0,0 +1 @@
export { default } from './loading-ellipsis';
@@ -0,0 +1,24 @@
.ellipsis {
display: inline-block;
}
.ellipsis:after {
content: '...';
position: absolute;
-webkit-animation: ellipsis steps(4, end) 1200ms infinite;
animation: ellipsis steps(4, end) 1500ms infinite;
width: 0px;
overflow: hidden;
}
@keyframes ellipsis {
to {
width: 1.3em;
}
}
@-webkit-keyframes ellipsis {
to {
width: 1.3em;
}
}
@@ -0,0 +1,11 @@
import styles from './loading-ellipsis.module.css';
interface LoadingEllipsisProps {
string: string;
}
const LoadingEllipsis = ({ string }: LoadingEllipsisProps) => {
return <span className={styles.ellipsis}>{string}</span>;
};
export default LoadingEllipsis;
+1
View File
@@ -0,0 +1 @@
export { default } from './markdown';
@@ -0,0 +1,25 @@
.markdown a {
color: var(--post-link-text-color);
text-decoration: none;
}
.markdown a:hover {
color: var(--post-link-text-color-hover);
text-decoration: none;
}
.markdown ol {
padding-left: 15px;
}
.markdown ul {
padding-left: 40px;
}
.hrWrapper {
padding: 0.5em 0;
}
.hrWrapper hr {
margin: 0;
}
+81
View File
@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import styles from './markdown.module.css';
import ReactMarkdown from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import supersub from 'remark-supersub';
import { visit } from 'unist-util-visit';
interface MarkdownProps {
content: string;
}
const MAX_LENGTH_FOR_GFM = 10000; // remarkGfm lags with large content
const blockquoteToGreentext = () => (tree: any) => {
tree.children.forEach((node: any) => {
if (node.type === 'blockquote') {
node.children.forEach((child: any) => {
if (child.type === 'paragraph' && child.children.length > 0) {
const prefix = {
type: 'text',
value: '>',
};
child.children.unshift(prefix);
}
});
node.type = 'div';
node.data = {
hName: 'div',
hProperties: {
className: 'greentext',
},
};
}
});
};
const Markdown = ({ content }: MarkdownProps) => {
const remarkPlugins: any[] = [[supersub]];
if (content.length <= MAX_LENGTH_FOR_GFM) {
remarkPlugins.push([remarkGfm, { singleTilde: false }]);
}
const customSchema = useMemo(
() => ({
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), 'div'],
attributes: {
...defaultSchema.attributes,
div: ['className'],
},
}),
[],
);
remarkPlugins.push([blockquoteToGreentext]);
return (
<span className={styles.markdown}>
<ReactMarkdown
children={content}
remarkPlugins={remarkPlugins}
rehypePlugins={[[rehypeSanitize, customSchema]]}
components={{
img: ({ src }) => <span>{src}</span>,
video: ({ src }) => <span>{src}</span>,
iframe: ({ src }) => <span>{src}</span>,
source: ({ src }) => <span>{src}</span>,
hr: () => (
<div className={styles.hrWrapper}>
<hr />
</div>
),
}}
/>
</span>
);
};
export default Markdown;
+1
View File
@@ -0,0 +1 @@
export { default } from './post';
+96
View File
@@ -0,0 +1,96 @@
.thread {
clear: both;
}
.hrWrapper hr {
margin: 0; /* margin bugs Virtuoso scrolling */
}
.hrWrapper {
padding-top: 0.5em; /* instead of using margin, wrap with padding */
padding-bottom: 0.5em;
}
.postDesktop .link a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.postDesktop .link a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.postDesktop .postInfo {
width: 100%;
}
.postDesktop .subject {
color: var(--post-subject-text-color);
font-weight: var(--post-subject-font-weight);
}
.postDesktop .name {
color: var(--post-name-text-color);
font-weight: var(--post-name-font-weight);
}
.postDesktop .postNumLink a, .postDesktop .postNumLink span {
color: unset;
text-decoration: unset;
}
.postDesktop .postNumLink a:hover, .postDesktop .postNumLink span:hover {
cursor: pointer;
color: var(--button-desktop-text-color-hover);
}
.postDesktop .stickyIconWrapper {
padding-left: 5px;
}
.postDesktop .closedIconWrapper {
padding-left: 3px;
}
.postDesktop .stickyIconWrapper img, .postDesktop .closedIconWrapper img {
width: 16px;
height: 16px;
border: none;
image-rendering: pixelated;
}
.postDesktop .replyButton {
padding-left: 10px;
}
.postDesktop .replyButton a {
color: var(--button-desktop-text-color);
text-decoration: var(--button-desktop-text-decoration);
text-transform: capitalize;
}
.postDesktop .replyButton a:hover {
cursor: pointer;
color: var(--button-desktop-text-color-hover);
}
.postMenuBtn {
padding-left: 5px;
color: var(--post-menu-button-color);
}
.postMenuBtn:hover {
cursor: pointer;
color: var(--post-menu-button-color-hover);
}
.postDesktop .postMessage {
padding: 1em 40px;
}
@media (max-width: 640px) {
.postDesktop {
display: none;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Comment } from '@plebbit/plebbit-react-hooks';
import styles from './post.module.css';
import useReplies from '../../hooks/use-replies';
import { getLinkMediaInfoMemoized } from '../../lib/utils/media-utils';
import { getFormattedDate } from '../../lib/utils/time-utils';
import Markdown from '../markdown';
const Post = ({ post }: Comment) => {
const { t } = useTranslation();
const { author, cid, content, link, locked, pinned, postCid, shortCid, subplebbitAddress, timestamp, title } = post || {};
const { displayName, shortAddress } = author || {};
const replies = useReplies(post);
const linkMediaInfo = getLinkMediaInfoMemoized(link);
return (
<div className={styles.thread}>
<div className={styles.postContainer}>
<div className={styles.postDesktop}>
<div className={styles.hrWrapper}>
<hr />
</div>
{linkMediaInfo?.url && (
<div className={styles.link}>
{t('link')}:{' '}
<a href={linkMediaInfo?.url} target='_blank' rel='noopener noreferrer'>
{linkMediaInfo.url.length > 30 ? linkMediaInfo?.url.slice(0, 30) + '...' : linkMediaInfo?.url}
</a>{' '}
({linkMediaInfo?.type})
</div>
)}
<div className={styles.postInfo}>
{title && <span className={styles.subject}>{title.length > 75 ? title.slice(0, 75) + '...' : title}</span>}{' '}
<span className={styles.nameBlock}>
<span className={styles.name}>{displayName || 'Anonymous'} </span>
<span className={styles.userAddress}>(u/{shortAddress}) </span>
</span>
<span className={styles.dateTime}>{getFormattedDate(timestamp)} </span>
<span className={styles.postNum}>
<span className={styles.postNumLink}>
<Link to={`/p/${subplebbitAddress}/c/${cid}`} className={styles.linkToPost} title={t('link_to_post')}>
c/
</Link>
<span className={styles.replyToPost} title={t('reply_to_post')}>
{shortCid}
</span>
</span>
{pinned && (
<span className={styles.stickyIconWrapper}>
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
</span>
)}
{locked && (
<span className={styles.closedIconWrapper}>
<img src='assets/icons/closed.gif' alt='' className={styles.closedIcon} title={t('closed')} />
</span>
)}
<span className={styles.replyButton}>
[<Link to={`/p/${subplebbitAddress}/c/${postCid}`}>{t('reply')}</Link>]
</span>
</span>
<span className={styles.postMenuBtn}></span>
</div>
{content && (
<div className={styles.postMessage}>
<blockquote>
<Markdown content={content} />
</blockquote>
</div>
)}
</div>
</div>
</div>
);
};
export default Post;
+99
View File
@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import useStateString from './use-state-string';
import { useSubplebbit, useSubplebbitsStates } from '@plebbit/plebbit-react-hooks';
const clientHosts: { [key: string]: string } = {};
const getClientHost = (clientUrl: string): string => {
if (!clientHosts[clientUrl]) {
try {
clientHosts[clientUrl] = new URL(clientUrl).hostname || clientUrl;
} catch (e) {
clientHosts[clientUrl] = clientUrl;
}
}
return clientHosts[clientUrl];
};
const useFeedStateString = (subplebbitAddresses?: string[]): string | undefined => {
// single subplebbit feed state string
const subplebbitAddress = subplebbitAddresses?.length === 1 ? subplebbitAddresses[0] : undefined;
const subplebbit = useSubplebbit({ subplebbitAddress });
const singleSubplebbitFeedStateString = useStateString(subplebbit);
// multiple subplebbit feed state string
const { states } = useSubplebbitsStates({ subplebbitAddresses });
const multipleSubplebbitsFeedStateString = useMemo(() => {
if (subplebbitAddress) {
return;
}
// e.g. Resolving 2 addresses from infura.io, fetching 2 IPNS, 1 IPFS from cloudflare-ipfs.com, ipfs.io
let stateString = '';
if (states['resolving-address']) {
const { subplebbitAddresses, clientUrls } = states['resolving-address'];
if (subplebbitAddresses.length && clientUrls.length) {
stateString += `resolving ${subplebbitAddresses.length} ${subplebbitAddresses.length === 1 ? 'address' : 'addresses'} from ${clientUrls
.map(getClientHost)
.join(', ')}`;
}
}
// find all page client and sub addresses
const pagesStatesClientHosts = new Set();
const pagesStatesSubplebbitAddresses = new Set();
for (const state in states) {
if (state.match('page')) {
states[state].clientUrls.forEach((clientUrl) => pagesStatesClientHosts.add(getClientHost(clientUrl)));
states[state].subplebbitAddresses.forEach((subplebbitAddress) => pagesStatesSubplebbitAddresses.add(subplebbitAddress));
}
}
if (states['fetching-ipns'] || states['fetching-ipfs'] || pagesStatesSubplebbitAddresses.size) {
// separate 2 different states using ', '
if (stateString) {
stateString += ', ';
}
// find all client urls
const clientHosts = new Set([...pagesStatesClientHosts]);
states['fetching-ipns']?.clientUrls.forEach((clientUrl) => clientHosts.add(getClientHost(clientUrl)));
states['fetching-ipfs']?.clientUrls.forEach((clientUrl) => clientHosts.add(getClientHost(clientUrl)));
if (clientHosts.size) {
stateString += 'fetching ';
if (states['fetching-ipns']) {
stateString += `${states['fetching-ipns'].subplebbitAddresses.length} IPNS`;
}
if (states['fetching-ipfs']) {
if (states['fetching-ipns']) {
stateString += ', ';
}
stateString += `${states['fetching-ipfs'].subplebbitAddresses.length} IPFS`;
}
if (pagesStatesSubplebbitAddresses.size) {
if (states['fetching-ipns'] || states['fetching-ipfs']) {
stateString += ', ';
}
stateString += `${pagesStatesSubplebbitAddresses.size} ${pagesStatesSubplebbitAddresses.size === 1 ? 'page' : 'pages'}`;
}
stateString += ` from ${[...clientHosts].join(', ')}`;
}
}
// capitalize first letter
stateString = stateString.charAt(0).toUpperCase() + stateString.slice(1);
// if string is empty, return undefined instead
return stateString === '' ? undefined : stateString;
}, [states, subplebbitAddress]);
if (singleSubplebbitFeedStateString) {
return singleSubplebbitFeedStateString;
}
return multipleSubplebbitsFeedStateString;
};
export default useFeedStateString;
+29
View File
@@ -0,0 +1,29 @@
import { useMemo, useCallback } from 'react';
import { Comment, useAccountComments } from '@plebbit/plebbit-react-hooks';
const useRepliesAndAccountReplies = (comment: Comment) => {
// filter only the parent cid
const filter = useCallback((accountComment: Comment) => accountComment.parentCid === (comment?.cid || 'n/a'), [comment?.cid]);
const { accountComments } = useAccountComments({ filter });
// the account's replies have a delay before getting published, so get them locally from accountComments instead
const accountRepliesNotYetPublished = useMemo(() => {
const replies = comment?.replies?.pages?.topAll?.comments || [];
const replyCids = new Set(replies.map((reply: Comment) => reply?.cid));
// filter out the account comments already in comment.replies, so they don't appear twice
return accountComments.filter((accountReply) => !replyCids.has(accountReply?.cid));
}, [comment?.replies?.pages?.topAll?.comments, accountComments]);
const repliesAndNotYetPublishedReplies = useMemo(() => {
return [
// put the author's unpublished replies at the top, latest first (reverse)
...accountRepliesNotYetPublished.reverse(),
// put the published replies after,
...(comment?.replies?.pages?.topAll?.comments || []),
];
}, [comment?.replies?.pages?.topAll?.comments, accountRepliesNotYetPublished]);
return repliesAndNotYetPublishedReplies;
};
export default useRepliesAndAccountReplies;
+67
View File
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useClientsStates } from '@plebbit/plebbit-react-hooks';
interface CommentOrSubplebbit {
state?: string;
publishingState?: string;
updatingState?: string;
}
interface States {
[key: string]: string[];
}
const clientHosts: { [key: string]: string } = {};
const getClientHost = (clientUrl: string): string => {
if (!clientHosts[clientUrl]) {
try {
clientHosts[clientUrl] = new URL(clientUrl).hostname || clientUrl;
} catch (e) {
clientHosts[clientUrl] = clientUrl;
}
}
return clientHosts[clientUrl];
};
const useStateString = (commentOrSubplebbit: CommentOrSubplebbit): string | undefined => {
const { states } = useClientsStates({ comment: commentOrSubplebbit }) as { states: States };
return useMemo(() => {
let stateString: string | undefined = '';
for (const state in states) {
const clientUrls = states[state];
const clientHosts = clientUrls.map((clientUrl) => getClientHost(clientUrl));
if (clientHosts.length === 0) {
continue;
}
if (stateString) {
stateString += ', ';
}
const formattedState = state.replaceAll('-', ' ').replace('ipfs', 'IPFS').replace('ipns', 'IPNS');
stateString += `${formattedState} from ${clientHosts.join(', ')}`;
}
if (!stateString && commentOrSubplebbit?.state !== 'succeeded') {
if (commentOrSubplebbit?.publishingState && commentOrSubplebbit?.publishingState !== 'stopped' && commentOrSubplebbit?.publishingState !== 'succeeded') {
stateString = commentOrSubplebbit.publishingState;
} else if (commentOrSubplebbit?.updatingState !== 'stopped' && commentOrSubplebbit?.updatingState !== 'succeeded') {
stateString = commentOrSubplebbit.updatingState;
}
if (stateString) {
stateString = stateString.replaceAll('-', ' ').replace('ipfs', 'IPFS').replace('ipns', 'IPNS');
}
}
if (stateString) {
stateString = stateString.charAt(0).toUpperCase() + stateString.slice(1);
}
return stateString === '' ? undefined : stateString;
}, [states, commentOrSubplebbit]);
};
export default useStateString;
+11 -1
View File
@@ -7,11 +7,21 @@ body {
background-color: white;
}
body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, code, form, fieldset, legend, input, textarea, p, blockquote, th, td, iframe {
body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, code, form, fieldset, legend, input, textarea, p, th, td, iframe, blockquote {
margin: 0;
padding: 0;
}
hr {
border: var(--hr-border, revert);
border-top: var(--hr-border-top, revert);
height: var(--hr-height, revert);
}
.greentext {
color: var(--post-greentext-color);
}
@media (max-width: 640px) {
.button {
text-transform: capitalize;
+100
View File
@@ -0,0 +1,100 @@
import { Comment } from '@plebbit/plebbit-react-hooks';
import extName from 'ext-name';
import { canEmbed } from '../../components/embed';
import memoize from 'memoizee';
import { isValidURL } from './url-utils';
export interface CommentMediaInfo {
url: string;
type: string;
thumbnail?: string;
patternThumbnailUrl?: string;
}
export const getHasThumbnail = (commentMediaInfo: CommentMediaInfo | undefined, link: string | undefined): boolean => {
const iframeThumbnail = commentMediaInfo?.patternThumbnailUrl || commentMediaInfo?.thumbnail;
return link &&
commentMediaInfo &&
(commentMediaInfo.type === 'image' ||
commentMediaInfo.type === 'video' ||
commentMediaInfo.type === 'gif' ||
(commentMediaInfo.type === 'webpage' && commentMediaInfo.thumbnail) ||
(commentMediaInfo.type === 'iframe' && iframeThumbnail))
? true
: false;
};
const getYouTubeVideoId = (url: URL): string | null => {
if (url.host.includes('youtu.be')) {
return url.pathname.slice(1);
} else if (url.searchParams.has('v')) {
return url.searchParams.get('v');
} else if (url.host.startsWith('yt.')) {
return url.searchParams.get('v');
}
return null;
};
const getPatternThumbnailUrl = (url: URL): string | undefined => {
const videoId = getYouTubeVideoId(url);
if (videoId) {
return `https://img.youtube.com/vi/${videoId}/0.jpg`;
}
if (url.host.includes('streamable.com')) {
const videoId = url.pathname.split('/')[1];
return `https://cdn-cf-east.streamable.com/image/${videoId}.jpg`;
}
};
const getLinkMediaInfo = (link: string): CommentMediaInfo | undefined => {
if (!isValidURL(link)) {
return;
}
const url = new URL(link);
let patternThumbnailUrl: string | undefined;
let type: string = 'webpage';
let mime: string | undefined;
// Check for common dynamic image URL patterns
if (link.includes('/_next/image?')) {
// Next.js Image component
return { url: link, type: 'image' };
}
try {
mime = extName(url.pathname.slice(url.pathname.lastIndexOf('/') + 1))[0]?.mime;
if (mime) {
if (mime.startsWith('image')) {
type = mime === 'image/gif' ? 'gif' : 'image';
} else if (mime.startsWith('video')) {
type = 'video';
} else if (mime.startsWith('audio')) {
type = 'audio';
}
}
if (canEmbed(url) || url.host.startsWith('yt.')) {
type = 'iframe';
patternThumbnailUrl = getPatternThumbnailUrl(url);
}
} catch (e) {
console.error(e);
}
return { url: link, type, patternThumbnailUrl };
};
const getCommentMediaInfo = (comment: Comment): CommentMediaInfo | undefined => {
if (!comment?.thumbnailUrl && !comment?.link) {
return;
}
const linkInfo = comment.link ? getLinkMediaInfo(comment.link) : undefined;
if (linkInfo) {
linkInfo.thumbnail = comment.thumbnailUrl || linkInfo.thumbnail;
return linkInfo;
}
return;
};
export const getCommentMediaInfoMemoized = memoize(getCommentMediaInfo, { max: 1000 });
export const getLinkMediaInfoMemoized = memoize(getLinkMediaInfo, { max: 1000 });
+33
View File
@@ -0,0 +1,33 @@
export const getFormattedDate = (commentTimestamp: number) => {
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
return '';
}
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
const string = new Intl.DateTimeFormat(locale, {
hour12: false,
year: '2-digit',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(commentTimestamp * 1000));
if (locale.startsWith('ar')) {
return string;
}
const items = string.split(/,* /);
if (items.length === 3) {
const itemIsNumber = [items[0][0].match(/[0-9]/), items[1][0].match(/[0-9]/)];
if (itemIsNumber[0] && itemIsNumber[1]) {
return `${items[0]}(${items[2]})${items[1]}`;
}
if (itemIsNumber[0] && !itemIsNumber[1]) {
return `${items[0]}(${items[1]})${items[2]}`;
}
if (!itemIsNumber[0] && itemIsNumber[1]) {
return `${items[1]}(${items[0]})${items[2]}`;
}
}
return string;
};
+26
View File
@@ -0,0 +1,26 @@
export const getHostname = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch (e) {
return '';
}
};
export const isValidURL = (url: string) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export const copyShareLinkToClipboard = (subplebbitAddress: string, cid: string) => {
const shareLink = `https://pleb.bz/p/${subplebbitAddress}/c/${cid}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(shareLink);
} else {
alert('Your browser does not support clipboard API');
}
};
+115 -39
View File
@@ -8,6 +8,10 @@
/* board nav */
--boardnav-font-size: 9pt;
--boardnav-separator-color: #b86;
--boardnav-desktop-link-text-color: #800;
--boardnav-desktop-link-text-color-hover: #e00;
--boardnav-desktop-link-text-decoration: none;
--boardnav-desktop-link-text-decoration-hover: underline;
--boardnav-mobile-background-color: #f0e0d6;
--boardnav-mobile-border-bottom: 2px solid #d9c5b7;
--boardnav-mobile-font-size: 10px;
@@ -46,17 +50,30 @@
--homepage-box-bar-text-color: #800;
--homepage-box-bar-text-color-hover: #c63;
--homepage-box-bar-background-color: #fca;
--homepage-infobox-link-text-color: #00f;
--homepage-infobox-link-text-decoration: underline;
--homepage-box-link-text-color: #800;
--homepage-box-link-text-color-hover: #e00;
--homepage-box-link-text-decoration: none;
--homepage-box-link-text-decoration-hover: underline;
/* footer */
--footer-item-background-color-desktop: #fed;
/* links */
--link-external-text-color: #00f;
--link-external-text-decoration: underline;
--link-internal-text-color: #800;
--link-internal-text-color-hover: #e00;
--link-internal-text-decoration: none;
--link-internal-text-decoration-hover: underline;
--post-link-text-color: #00e;
--post-link-text-decoration: underline;
--post-link-text-color-hover: red;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #cc1105;
--post-subject-font-weight: 700;
--post-name-text-color: #117743;
--post-name-font-weight: 700;
--post-menu-button-color: #000080;
--post-menu-button-color-hover: red;
--post-greentext-color: #789922;
/* post form */
--post-form-toggle-font-size: 22px;
@@ -75,6 +92,10 @@
/* board nav */
--boardnav-font-size: 9pt;
--boardnav-separator-color: #89a;
--boardnav-desktop-link-text-color: #34345c;
--boardnav-desktop-link-text-color-hover: #d00;
--boardnav-desktop-link-text-decoration: none;
--boardnav-desktop-link-text-decoration-hover: none;
--boardnav-mobile-background-color: #d6daf0;
--boardnav-mobile-border-bottom: 2px solid #b7c5d9;
--boardnav-mobile-font-size: 10px;
@@ -96,6 +117,12 @@
--homepage-box-bar-text-color: #000;
--homepage-box-bar-text-color-hover: #fff;
--homepage-box-bar-background-color: #98e;
--homepage-infobox-link-text-color: #00f;
--homepage-infobox-link-text-decoration: underline;
--homepage-box-link-text-color: #34345c;
--homepage-box-link-text-color-hover: #d00;
--homepage-box-link-text-decoration: none;
--homepage-box-link-text-decoration-hover: underline;
/* desktop buttons */
--button-desktop-text-color: #34345c;
@@ -117,12 +144,19 @@
--hr-border-top: 1px solid #b7c5d9;
/* links */
--link-external-text-color: #00f;
--link-external-text-decoration: underline;
--link-internal-text-color: #34345c;
--link-internal-text-color-hover: #d00;
--link-internal-text-decoration: none;
--link-internal-text-decoration-hover: underline;
--post-link-text-color: #34345c;
--post-link-text-decoration: underline;
--post-link-text-color-hover: #d00;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #0f0c5d;
--post-subject-font-weight: 700;
--post-name-text-color: #117743;
--post-name-font-weight: 700;
--post-menu-button-color: #34345c;
--post-menu-button-color-hover: #d00;
--post-greentext-color: #789922;
/* post form */
--post-form-toggle-font-size: 22px;
@@ -137,19 +171,13 @@
--body-font-family: times new roman, serif;
--body-font-size: 12pt;
/* homepage */
--homepage-infobox-text-color: #000;
--homepage-infobox-bar-text-color: #fff;
--homepage-infobox-bar-background-color: #800;
--homepage-box-background-color: #fff;
--homepage-box-border-color: #800;
--homepage-box-bar-text-color: #800;
--homepage-box-bar-text-color-hover: #c63;
--homepage-box-bar-background-color: #fca;
/* board nav */
--boardnav-font-size: 11pt;
--boardnav-separator-color: none;
--boardnav-desktop-link-text-color: #00e;
--boardnav-desktop-link-text-color-hover: red;
--boardnav-desktop-link-text-decoration: underline;
--boardnav-desktop-link-text-decoration-hover: underline;
--boardnav-mobile-background-color: #f0e0d6;
--boardnav-mobile-border-bottom: 2px solid #d9c5b7;
--boardnav-mobile-font-size: 11pt;
@@ -176,12 +204,19 @@
--footer-item-background-color-desktop: #fed;
/* links */
--link-external-text-color: #00f;
--link-external-text-decoration: underline;
--link-internal-text-color: #00e;
--link-internal-text-color-hover: #e00;
--link-internal-text-decoration: underline;
--link-internal-text-decoration-hover: underline;
--post-link-text-color: #00e;
--post-link-text-decoration: underline;
--post-link-text-color-hover: red;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #cc1105;
--post-subject-font-weight: 700;
--post-name-text-color: #117743;
--post-name-font-weight: 700;
--post-menu-button-color: #000080;
--post-menu-button-color-hover: red;
--post-greentext-color: #789922;
/* post form */
--post-form-toggle-font-size: 22px;
@@ -193,13 +228,17 @@
--banner-image-border: 1px solid #000;
--board-title-font-size: 32px;
--board-title-text-color: #af0a0f;
--board-title-font-family: Tahoma, sans-serif;
--board-title-font-family: times new roman, serif;
--board-address-font-size: 10pt;
--board-address-text-color: #af0a0f;
/* board nav */
--boardnav-font-size: 11pt;
--boardnav-separator-color: none;
--boardnav-desktop-link-text-color: #34345c;
--boardnav-desktop-link-text-color-hover: #d00;
--boardnav-desktop-link-text-decoration: underline;
--boardnav-desktop-link-text-decoration-hover: underline;
--boardnav-mobile-background-color: #d6daf0;
--boardnav-mobile-border-bottom: 2px solid #b7c5d9;
--boardnav-mobile-font-size: 11pt;
@@ -239,13 +278,20 @@
--footer-item-background-color-desktop: #eef2ff;
/* links */
--link-external-text-color: #00f;
--link-external-text-decoration: underline;
--link-internal-text-color: #34345c;
--link-internal-text-color-hover: #d00;
--link-internal-text-decoration: underline;
--link-internal-text-decoration-hover: underline;
--post-link-text-color: #34345c;
--post-link-text-decoration: underline;
--post-link-text-color-hover: #d00;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #0f0c5d;
--post-subject-font-weight: 700;
--post-name-text-color: #117743;
--post-name-font-weight: 700;
--post-menu-button-color: #34345c;
--post-menu-button-color-hover: #d00;
--post-greentext-color: #789922;
/* post form */
--post-form-toggle-font-size: 22px;
--post-form-toggle-font-weight: 700;
@@ -272,6 +318,10 @@
/* board nav */
--boardnav-font-size: 9pt;
--boardnav-separator-color: #c5c8c6;
--boardnav-desktop-link-text-color: #81a2be;
--boardnav-desktop-link-text-color-hover: #5f89ac;
--boardnav-desktop-link-text-decoration: none;
--boardnav-desktop-link-text-decoration-hover: none;
--boardnav-mobile-background-color: #1d1f21;
--boardnav-mobile-border-bottom: 2px solid #282a2e;
--boardnav-mobile-font-size: 10px;
@@ -300,8 +350,19 @@
--hr-height: 0px;
/* links */
--link-internal-text-color: #81a2be;
--link-internal-text-color-hover: #5f89ac;
--post-link-text-color: #81a2be;
--post-link-text-decoration: underline;
--post-link-text-color-hover: #5f89ac;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #b294bb;
--post-subject-font-weight: 700;
--post-name-text-color: #c5c8c6;
--post-name-font-weight: 700;
--post-menu-button-color: #5F89AC;
--post-menu-button-color-hover: #81a2be;
--post-greentext-color: #b5bd68;
/* post form */
--post-form-toggle-font-size: 22px;
@@ -324,6 +385,10 @@
/* board nav */
--boardnav-font-size: 9pt;
--boardnav-separator-color: #333;
--boardnav-desktop-link-text-color: #f60;
--boardnav-desktop-link-text-color-hover: #f30;
--boardnav-desktop-link-text-decoration: none;
--boardnav-desktop-link-text-decoration-hover: none;
--boardnav-mobile-background-color: #ddd;
--boardnav-mobile-border-bottom: 2px solid #ccc;
--boardnav-mobile-font-size: 10px;
@@ -352,8 +417,19 @@
--hr-height: 0px;
/* links */
--link-internal-text-color: #f60;
--link-internal-text-color-hover: #f30;
--post-link-text-color: #f60;
--post-link-text-decoration: underline;
--post-link-text-color-hover: #f30;
--post-link-text-decoration-hover: underline;
/* post */
--post-subject-text-color: #111;
--post-subject-font-weight: 700;
--post-name-text-color: #004a99;
--post-name-font-weight: 700;
--post-menu-button-color: #FF6600;
--post-menu-button-color-hover: #FF3300;
--post-greentext-color: #789922;
/* post form */
--post-form-toggle-font-size: 22px;
+6 -6
View File
@@ -72,7 +72,7 @@
}
.boxContent a, .boxContent a:visited{
color: var(--link-external-text-color);
color: var(--homepage-infobox-link-text-color);
}
.searchBar {
@@ -116,13 +116,13 @@
.list a {
display: inline-block;
color: var(--link-internal-text-color);
text-decoration: var(--link-internal-text-decoration);
color: var(--homepage-box-link-text-color);
text-decoration: var(--homepage-box-link-text-decoration);
}
.list a:hover {
color: var(--link-internal-text-color-hover);
text-decoration: var(--link-internal-text-decoration-hover);
color: var(--homepage-box-link-text-color-hover);
text-decoration: var(--homepage-box-link-text-decoration-hover);
}
.boardsBoxContent {
@@ -175,7 +175,7 @@
}
.footer a {
color: var(--link-external-text-color);
color: var(--homepage-infobox-link-text-color);
text-decoration: underline;
}
}
+4
View File
@@ -5,6 +5,10 @@
transform: translate(-50%, -50%);
}
.version {
display: block;
}
.content select {
background-color: var(--select-background-color, revert);
border: var(--select-border, revert);
+23
View File
@@ -2,6 +2,9 @@ import { useTranslation } from 'react-i18next';
import useTheme from '../../hooks/use-theme';
import styles from './settings.module.css';
import { Link } from 'react-router-dom';
import packageJson from '../../../package.json';
const isElectron = window.isElectron === true;
const commitRef = process.env.REACT_APP_COMMIT_REF;
// TODO: remove theme and languages selectors for debugging
const ThemeSettings = () => {
@@ -46,6 +49,26 @@ const LanguageSettings = () => {
const Settings = () => {
return (
<div className={styles.content}>
<div className={styles.version}>
<a href={`https://github.com/plebbit/plebchan/releases/tag/v${packageJson.version}`} target='_blank' rel='noopener noreferrer'>
v{packageJson.version}
</a>
{isElectron && (
<a className={styles.fullNodeStats} href='http://localhost:5001/webui/' target='_blank' rel='noreferrer'>
node stats
</a>
)}
{commitRef && (
<>
{' '}
(
<a href={`https://github.com/plebbit/plebchan/commit/${commitRef}`} target='_blank' rel='noopener noreferrer'>
{commitRef.slice(0, 7)}
</a>
)
</>
)}
</div>
<div>
<Link to='/'>Home</Link>
</div>
+66 -1
View File
@@ -1,7 +1,72 @@
import { useEffect, useMemo, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useTranslation } from 'react-i18next';
import styles from './subplebbit.module.css';
import useFeedStateString from '../../hooks/use-feed-state-string';
import LoadingEllipsis from '../../components/loading-ellipsis';
import Post from '../../components/post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const Subplebbit = () => {
return <div className={styles.content}></div>;
const { t } = useTranslation();
const { subplebbitAddress } = useParams<{ subplebbitAddress: string }>();
const subplebbitAddresses = useMemo(() => [subplebbitAddress], [subplebbitAddress]) as string[];
const subplebbit = useSubplebbit({ subplebbitAddress });
const { createdAt, description, roles, rules, shortAddress, state, title, updatedAt, settings } = subplebbit || {};
const sortType = 'active';
const { feed, hasMore, loadMore } = useFeed({ subplebbitAddresses, sortType });
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
const loadingString = <div className={styles.stateString}>{state === 'failed' ? state : <LoadingEllipsis string={loadingStateString} />}</div>;
useEffect(() => {
document.title = title ? title : shortAddress;
}, [title, shortAddress]);
const Footer = () => {
let footerContent;
if (feed.length === 0) {
footerContent = t('no_posts');
}
if (hasMore || subplebbitAddresses.length === 0) {
footerContent = loadingString;
}
return <div className={styles.footer}>{footerContent}</div>;
};
// save the last Virtuoso state to restore it when navigating back
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
useEffect(() => {
const setLastVirtuosoState = () => {
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
if (snapshot?.ranges?.length) {
lastVirtuosoStates[subplebbitAddress + sortType] = snapshot;
}
});
};
window.addEventListener('scroll', setLastVirtuosoState);
return () => window.removeEventListener('scroll', setLastVirtuosoState);
}, [subplebbitAddress, sortType]);
const lastVirtuosoState = lastVirtuosoStates?.[subplebbitAddress + sortType];
return (
<div className={styles.content}>
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 600 }}
totalCount={feed?.length || 0}
data={feed}
itemContent={(index, post) => <Post index={index} post={post} />}
useWindowScroll={true}
components={{ Footer }}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
</div>
);
};
export default Subplebbit;
+807 -1379
View File
File diff suppressed because it is too large Load Diff