fix(directory): load per-directory list files (#1134)

Merge board directories from the new bitsocialnet/lists per-directory layout on the client side.

Update vendored fallback data, sync tooling, directory references, and tests.

Treat failed board stat fetches as complete on the home page so stats do not stay loading forever.
This commit is contained in:
Tommaso Casaburi
2026-05-20 23:31:58 +07:00
committed by GitHub
parent c8edc92983
commit 536d9ac0b8
21 changed files with 1671 additions and 938 deletions
@@ -48,9 +48,9 @@ const CreateBoardModal = () => {
<div className={styles.section}>
<h3>Submitting to a Directory</h3>
<p>
Open a PR editing{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
5chan-directories.json
Open a PR editing the relevant file in the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' target='_blank' rel='noopener noreferrer'>
5chan-directories folder
</a>{' '}
with your board&apos;s title, address, and NSFW status. Devs will review and merge if approved. 99% uptime is required.
</p>
@@ -56,9 +56,9 @@ const DirectoryModal = () => {
<div className={styles.section}>
<h3>Directory Submission</h3>
<p>
To submit your board, open a PR editing{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' target='_blank' rel='noopener noreferrer'>
5chan-directories.json
To submit your board, open a PR editing the relevant file in the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' target='_blank' rel='noopener noreferrer'>
5chan-directories folder
</a>{' '}
with your board&apos;s title, address, and NSFW status. Requirements: 99% uptime (they&apos;re P2P nodes), active, well-moderated, and relevant to the
category. Devs will review and approve/reject based on these criteria.
-530
View File
@@ -1,530 +0,0 @@
{
"title": "/all/ - All 5chan Directories",
"description": "Each 5chan directory is assigned to a board handpicked by the team, until DAO curation is implemented.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories.json",
"createdAt": 1762179811,
"updatedAt": 1779102921,
"communities": [
{
"name": "anime-and-manga.bso",
"publicKey": "12D3KooWMDt3RMVMHrm2pEEyfraBtHP86dmkim7EVAapDj3Mchdy",
"title": "/a/ - Anime & Manga",
"directoryCode": "a",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "comics-and-cartoons.bso",
"publicKey": "12D3KooWRLmyg671KVYb1xekZ8eTQSMfRraB3DmQZPsKbCdMsEuc",
"title": "/co/ - Comics & Cartoons",
"directoryCode": "co",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "food-and-cooking.bso",
"publicKey": "12D3KooWFfANpSeemwvqqGsXNPHDuyDMCK6wE8cxGTuNw7xoWUp7",
"title": "/ck/ - Food & Cooking",
"directoryCode": "ck",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "politically-incorrect.bso",
"publicKey": "12D3KooWMVob74DQoTLGZ4B8kWgPwDfbtiWqdJE4DrGtf2rmVw36",
"title": "/pol/ - Politically Incorrect",
"directoryCode": "pol",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 20
},
"nsfw": true
},
{
"name": "business-and-finance.bso",
"publicKey": "12D3KooWNMybS8JqELi38ZBX897PrjWbCrGoMKfw3bgoqzC2n1Dh",
"title": "/biz/ - Business & Finance",
"directoryCode": "biz",
"features": {
"pseudonymityMode": "per-post",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 20
},
"nsfw": false
},
{
"name": "science-and-math.bso",
"publicKey": "12D3KooWEtHqwtEpgHsXS8VSFcGDt2DhzeqaLm9tTCRFucXaWp62",
"title": "/sci/ - Science & Math",
"directoryCode": "sci",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "technology-posting.bso",
"publicKey": "12D3KooWFnLrUYHpvqki7gbL4w9JzdxjpQPKE2JBDEd23Ly6X82X",
"title": "/g/ - Technology",
"directoryCode": "g",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "videogames-posting.bso",
"publicKey": "12D3KooWRAbo3HU5ThCAkJGQ5q7MTEJfhAX6Hu7xHyPtxHGi1Fgh",
"title": "/v/ - Video Games",
"directoryCode": "v",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "videogame-generals.bso",
"publicKey": "12D3KooWMvAcJFV45CS3hSZcx7EFhwhQrXfXhVHXQJFPfKeakWfq",
"title": "/vg/ - Video Game Generals",
"directoryCode": "vg",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 20
},
"nsfw": false
},
{
"name": "retro-games.bso",
"publicKey": "12D3KooWJfwp2WRwU3gsaNNPjZ1iHgxc1QFySr361cQarkhLp1fY",
"title": "/vr/ - Retro Games",
"directoryCode": "vr",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "fitness-posting.bso",
"publicKey": "12D3KooWHcqcJJmjAiBJA3uyhSW6ep4HFadUfteDrzU1Pn9ALkkw",
"title": "/fit/ - Fitness",
"directoryCode": "fit",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "sports-posting.bso",
"publicKey": "12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE",
"title": "/sp/ - Sports",
"directoryCode": "sp",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "traditional-games.bso",
"publicKey": "12D3KooWK6G3EHUCkfcgX6kPDihXJAdpucrZvn4WjPwf7fF484nt",
"title": "/tg/ - Traditional Games",
"directoryCode": "tg",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "advice-posting.bso",
"publicKey": "12D3KooWH5UFNs9yfwJVsSUhEW5bYrtF8KmzfeJzwg84qWMoFJVY",
"title": "/adv/ - Advice",
"directoryCode": "adv",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "worksafe-gif.bso",
"publicKey": "12D3KooWAGHV9th2FN48KPvHMJwxen8h5mSM39pkaZWvvzcWMPr9",
"title": "/wsg/ - Worksafe GIF",
"directoryCode": "wsg",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "do-it-yourself.bso",
"publicKey": "12D3KooWKKbiaFu7x2EwueN6wxw3DJRQsvCv8KVRGbTTYpsGZXVa",
"title": "/diy/ - Do It Yourself",
"directoryCode": "diy",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "outdoors-posting.bso",
"publicKey": "12D3KooWQ2nPGxQxuSMY92kM3zKu1rBekJsXxXb5uNkHLDfSSxTm",
"title": "/out/ - Outdoors",
"directoryCode": "out",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "artwork-critique.bso",
"publicKey": "12D3KooWQZ5HkAspHUPGEVoSaAZgqachZGtxCXxAXYBDTreR4uWJ",
"title": "/ic/ - Artwork/Critique",
"directoryCode": "ic",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "music-posting.bso",
"publicKey": "12D3KooWQdQ6TkVA1Xe9zzaFP6vXBgsLeMAewpLpLwbsAYKivnQy",
"title": "/mu/ - Music",
"directoryCode": "mu",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "international-sfw.bso",
"publicKey": "12D3KooWMSUYcergS2BwbRzFynR5hQURc962W9iR81eTJKz89Q6n",
"title": "/int/ - International",
"directoryCode": "int",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "literature-posting.bso",
"publicKey": "12D3KooWKXimxiZWtgF3LoTdaW3btHhDzJN7P3qrsgHwRkow186x",
"title": "/lit/ - Literature",
"directoryCode": "lit",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "history-posting.bso",
"publicKey": "12D3KooWN7bzigSgU4rxayrPCjvq2ETJMN9Zv3cyazpRYh36QWiN",
"title": "/his/ - History & Humanities",
"directoryCode": "his",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "television-and-film.bso",
"publicKey": "12D3KooWGSRg4crzV5vXEya1ghXD7UBTqimqXTQvNUqy9NdzZNyd",
"title": "/tv/ - Television & Film",
"directoryCode": "tv",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "torrents-posting.bso",
"publicKey": "12D3KooWFwieYtPDEd3UBK5qG6HFZ3Qu8cDd4B4RcY2HJ9CBkfaZ",
"title": "/t/ - Torrents",
"directoryCode": "t",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": true
},
{
"name": "paranormal-posting.bso",
"publicKey": "12D3KooWFZn7r5gYKbq5YQMr1FYQ5eUwoZr41Tjd8KmTgQ4dxTTP",
"title": "/x/ - Paranormal",
"directoryCode": "x",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "very-important-posts.bso",
"publicKey": "12D3KooWNMnBJ5phkBWrcjfdnzdqPwCARmXwGWeZDKNJvCVPR8GT",
"title": "/vip/ - Very Important Posts",
"directoryCode": "vip",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": false
},
{
"name": "adult-gif.bso",
"publicKey": "12D3KooWJP8v5VUzKUfxr38BbhQPb5yidF3WapGQ6CjLctBV6QC1",
"title": "/gif/ - Adult GIF",
"directoryCode": "gif",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": true
},
{
"name": "international-nsfw.bso",
"publicKey": "12D3KooWPuYtTzrq8gFnv6yM1r4egy1rd4cxLr1nsXTjfdYkfwcN",
"title": "/bant/ - International/Random",
"directoryCode": "bant",
"features": {
"pseudonymityMode": "per-post",
"safeForWork": false,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": true
},
{
"name": "random-nsfw.bso",
"publicKey": "12D3KooWJkJYkuVbJQhapcVGpo8oNmsXsKmFRrFqc1SHezZW9fX8",
"title": "/b/ - Random",
"directoryCode": "b",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"nsfw": true
}
]
}
+800
View File
@@ -0,0 +1,800 @@
{
"title": "5chan directories",
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
"createdAt": 1779182014,
"updatedAt": 1779279785,
"directories": [
{
"directoryCode": "a",
"title": "/a/ - Anime & Manga",
"description": "Boards competing to host the /a/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-a-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "anime-and-manga.bso",
"publicKey": "12D3KooWMDt3RMVMHrm2pEEyfraBtHP86dmkim7EVAapDj3Mchdy",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "co",
"title": "/co/ - Comics & Cartoons",
"description": "Boards competing to host the /co/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-co-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "comics-and-cartoons.bso",
"publicKey": "12D3KooWRLmyg671KVYb1xekZ8eTQSMfRraB3DmQZPsKbCdMsEuc",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "ck",
"title": "/ck/ - Food & Cooking",
"description": "Boards competing to host the /ck/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-ck-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "food-and-cooking.bso",
"publicKey": "12D3KooWFfANpSeemwvqqGsXNPHDuyDMCK6wE8cxGTuNw7xoWUp7",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "pol",
"title": "/pol/ - Politically Incorrect",
"description": "Boards competing to host the /pol/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-pol-directory.json",
"features": {
"pseudonymityMode": "per-post",
"safeForWork": false,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 20
},
"createdAt": 1779182014,
"updatedAt": 1779212075,
"boards": [
{
"address": "politically-incorrect.bso",
"publicKey": "12D3KooWMVob74DQoTLGZ4B8kWgPwDfbtiWqdJE4DrGtf2rmVw36",
"owner": "rinse12.bso",
"addedAt": 1779182014
},
{
"address": "nothing-is-beyond-our-reach.bso",
"publicKey": "12D3KooWDpiDmJCGKbXB3RJFfATmRMzmwTkjimBvf8xKZmUx2L1P",
"owner": "plebeius.bso",
"addedAt": 1779212075
}
]
},
{
"directoryCode": "biz",
"title": "/biz/ - Business & Finance",
"description": "Boards competing to host the /biz/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-biz-directory.json",
"features": {
"pseudonymityMode": "per-post",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 20
},
"createdAt": 1779182014,
"updatedAt": 1779212075,
"boards": [
{
"address": "business-and-finance.bso",
"publicKey": "12D3KooWNMybS8JqELi38ZBX897PrjWbCrGoMKfw3bgoqzC2n1Dh",
"owner": "rinse12.bso",
"addedAt": 1779182014
},
{
"address": "bizraelis.bso",
"publicKey": "12D3KooWR7nTdKZqZ1twGWMfVsXYDGp1XAKUrnYznKP651jFrizE",
"owner": "plebeius.bso",
"addedAt": 1779212075
}
]
},
{
"directoryCode": "sci",
"title": "/sci/ - Science & Math",
"description": "Boards competing to host the /sci/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-sci-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "science-and-math.bso",
"publicKey": "12D3KooWEtHqwtEpgHsXS8VSFcGDt2DhzeqaLm9tTCRFucXaWp62",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "g",
"title": "/g/ - Technology",
"description": "Boards competing to host the /g/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-g-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "technology-posting.bso",
"publicKey": "12D3KooWFnLrUYHpvqki7gbL4w9JzdxjpQPKE2JBDEd23Ly6X82X",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "v",
"title": "/v/ - Video Games",
"description": "Boards competing to host the /v/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-v-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "videogames-posting.bso",
"publicKey": "12D3KooWRAbo3HU5ThCAkJGQ5q7MTEJfhAX6Hu7xHyPtxHGi1Fgh",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "vg",
"title": "/vg/ - Video Game Generals",
"description": "Boards competing to host the /vg/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-vg-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 20
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "videogame-generals.bso",
"publicKey": "12D3KooWMvAcJFV45CS3hSZcx7EFhwhQrXfXhVHXQJFPfKeakWfq",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "vr",
"title": "/vr/ - Retro Games",
"description": "Boards competing to host the /vr/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-vr-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "retro-games.bso",
"publicKey": "12D3KooWJfwp2WRwU3gsaNNPjZ1iHgxc1QFySr361cQarkhLp1fY",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "fit",
"title": "/fit/ - Fitness",
"description": "Boards competing to host the /fit/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-fit-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "fitness-posting.bso",
"publicKey": "12D3KooWHcqcJJmjAiBJA3uyhSW6ep4HFadUfteDrzU1Pn9ALkkw",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "sp",
"title": "/sp/ - Sports",
"description": "Boards competing to host the /sp/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-sp-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "sports-posting.bso",
"publicKey": "12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "tg",
"title": "/tg/ - Traditional Games",
"description": "Boards competing to host the /tg/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-tg-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "traditional-games.bso",
"publicKey": "12D3KooWK6G3EHUCkfcgX6kPDihXJAdpucrZvn4WjPwf7fF484nt",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "adv",
"title": "/adv/ - Advice",
"description": "Boards competing to host the /adv/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-adv-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "advice-posting.bso",
"publicKey": "12D3KooWH5UFNs9yfwJVsSUhEW5bYrtF8KmzfeJzwg84qWMoFJVY",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "wsg",
"title": "/wsg/ - Worksafe GIF",
"description": "Boards competing to host the /wsg/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-wsg-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "worksafe-gif.bso",
"publicKey": "12D3KooWAGHV9th2FN48KPvHMJwxen8h5mSM39pkaZWvvzcWMPr9",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "diy",
"title": "/diy/ - Do It Yourself",
"description": "Boards competing to host the /diy/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-diy-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "do-it-yourself.bso",
"publicKey": "12D3KooWKKbiaFu7x2EwueN6wxw3DJRQsvCv8KVRGbTTYpsGZXVa",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "out",
"title": "/out/ - Outdoors",
"description": "Boards competing to host the /out/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-out-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "outdoors-posting.bso",
"publicKey": "12D3KooWQ2nPGxQxuSMY92kM3zKu1rBekJsXxXb5uNkHLDfSSxTm",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "ic",
"title": "/ic/ - Artwork/Critique",
"description": "Boards competing to host the /ic/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-ic-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "artwork-critique.bso",
"publicKey": "12D3KooWQZ5HkAspHUPGEVoSaAZgqachZGtxCXxAXYBDTreR4uWJ",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "mu",
"title": "/mu/ - Music",
"description": "Boards competing to host the /mu/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-mu-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "music-posting.bso",
"publicKey": "12D3KooWQdQ6TkVA1Xe9zzaFP6vXBgsLeMAewpLpLwbsAYKivnQy",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "int",
"title": "/int/ - International",
"description": "Boards competing to host the /int/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-int-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "international-sfw.bso",
"publicKey": "12D3KooWMSUYcergS2BwbRzFynR5hQURc962W9iR81eTJKz89Q6n",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "lit",
"title": "/lit/ - Literature",
"description": "Boards competing to host the /lit/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-lit-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "literature-posting.bso",
"publicKey": "12D3KooWKXimxiZWtgF3LoTdaW3btHhDzJN7P3qrsgHwRkow186x",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "his",
"title": "/his/ - History & Humanities",
"description": "Boards competing to host the /his/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-his-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "history-posting.bso",
"publicKey": "12D3KooWN7bzigSgU4rxayrPCjvq2ETJMN9Zv3cyazpRYh36QWiN",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "tv",
"title": "/tv/ - Television & Film",
"description": "Boards competing to host the /tv/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-tv-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "television-and-film.bso",
"publicKey": "12D3KooWGSRg4crzV5vXEya1ghXD7UBTqimqXTQvNUqy9NdzZNyd",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "t",
"title": "/t/ - Torrents",
"description": "Boards competing to host the /t/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-t-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "torrents-posting.bso",
"publicKey": "12D3KooWFwieYtPDEd3UBK5qG6HFZ3Qu8cDd4B4RcY2HJ9CBkfaZ",
"owner": "rinse12.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "x",
"title": "/x/ - Paranormal",
"description": "Boards competing to host the /x/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-x-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "paranormal-posting.bso",
"publicKey": "12D3KooWFZn7r5gYKbq5YQMr1FYQ5eUwoZr41Tjd8KmTgQ4dxTTP",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "vip",
"title": "/vip/ - Very Important Posts",
"description": "Boards competing to host the /vip/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-vip-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "very-important-posts.bso",
"publicKey": "12D3KooWNMnBJ5phkBWrcjfdnzdqPwCARmXwGWeZDKNJvCVPR8GT",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "gif",
"title": "/gif/ - Adult GIF",
"description": "Boards competing to host the /gif/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-gif-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "adult-gif.bso",
"publicKey": "12D3KooWJP8v5VUzKUfxr38BbhQPb5yidF3WapGQ6CjLctBV6QC1",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "bant",
"title": "/bant/ - International/Random",
"description": "Boards competing to host the /bant/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-bant-directory.json",
"features": {
"pseudonymityMode": "per-post",
"safeForWork": false,
"hasFlags": true,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "international-nsfw.bso",
"publicKey": "12D3KooWPuYtTzrq8gFnv6yM1r4egy1rd4cxLr1nsXTjfdYkfwcN",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "b",
"title": "/b/ - Random",
"description": "Boards competing to host the /b/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-b-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779182014,
"updatedAt": 1779182014,
"boards": [
{
"address": "random-nsfw.bso",
"publicKey": "12D3KooWJkJYkuVbJQhapcVGpo8oNmsXsKmFRrFqc1SHezZW9fX8",
"owner": "plebeius.bso",
"addedAt": 1779182014
}
]
},
{
"directoryCode": "an",
"title": "/an/ - Animals & Nature",
"description": "Boards competing to host the /an/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-an-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": true,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1779262963,
"updatedAt": 1779262963,
"boards": [
{
"address": "animals-and-nature.bso",
"publicKey": "12D3KooWSpKszPM2c17KBgbnoRrkWPCHJosGGFg3bKnzarYhHeSc",
"owner": "plebeius.bso",
"addedAt": 1779262963
}
]
}
]
}
+143 -84
View File
@@ -78,6 +78,57 @@ const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
json: vi.fn().mockResolvedValue(body),
});
const getDirectoryCodeFromUrl = (url: unknown): string => String(url).match(/5chan-([a-z0-9]+)-directory\.json/)?.[1] ?? 'unknown';
const isDefaultsUrl = (url: unknown): boolean => String(url).endsWith('/5chan-directories-defaults.json');
const REMOTE_DIRECTORY_CODES = ['a', 'mu', 'biz'];
type RemoteDirectoryOverride = {
address?: string;
publicKey?: string;
title?: string;
features?: Record<string, unknown>;
};
const createRemoteDirectoryList = (code: string, board: RemoteDirectoryOverride = {}) => ({
createdAt: 3,
updatedAt: 4,
boards: [
{
address: board.address ?? `${code}-posting.bso`,
publicKey: board.publicKey ?? `${code}-public-key`,
addedAt: 3,
},
],
});
const createRemoteDefaults = (codes = REMOTE_DIRECTORY_CODES, overrides: Record<string, RemoteDirectoryOverride> = {}) => ({
title: '5chan Directory Defaults',
description: 'remote defaults',
createdAt: 1,
updatedAt: 10,
directories: Object.fromEntries(
codes.map((code) => [
code,
{
directoryCode: code,
title: overrides[code]?.title ?? `/${code}/ - Test`,
features: overrides[code]?.features ?? { safeForWork: true },
},
]),
),
});
const mockDirectoryListFetches = (overrides: Record<string, RemoteDirectoryOverride> = {}) => {
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return Promise.resolve(createFetchResponse(createRemoteDefaults(REMOTE_DIRECTORY_CODES, overrides)));
}
const code = getDirectoryCodeFromUrl(url);
const payload = createRemoteDirectoryList(code, overrides[code]);
return Promise.resolve(createFetchResponse(payload));
});
};
const flushEffects = async (count = 4) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
@@ -153,45 +204,25 @@ describe('use-directories', () => {
],
};
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
{
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25, nested: { ignore: true } },
},
{
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
},
{
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Duplicate Flash',
directoryCode: 'f',
},
],
};
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(cachedData));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
fetchMock.mockReturnValueOnce(pendingFetch.promise);
const pendingDefaults = createDeferred<ReturnType<typeof createFetchResponse>>();
const pendingFetches = new Map<string, Deferred<ReturnType<typeof createFetchResponse>>>();
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return pendingDefaults.promise;
}
const code = getDirectoryCodeFromUrl(url);
const deferred = createDeferred<ReturnType<typeof createFetchResponse>>();
pendingFetches.set(code, deferred);
return deferred.promise;
});
renderHarness();
await flushEffects();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
@@ -203,70 +234,64 @@ describe('use-directories', () => {
updatedAt: 2,
});
pendingFetch.resolve(createFetchResponse(remotePayload));
const remoteOverrides: Record<string, RemoteDirectoryOverride> = {
mu: {
address: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
features: { safeForWork: true, postsPerPage: 25, nested: { ignore: true } },
},
};
pendingDefaults.resolve(createFetchResponse(createRemoteDefaults(REMOTE_DIRECTORY_CODES, remoteOverrides)));
await flushEffects();
pendingFetches.forEach((pendingFetch, code) => {
const override = code === 'mu' ? remoteOverrides.mu : undefined;
pendingFetch.resolve(createFetchResponse(createRemoteDirectoryList(code, override)));
});
await flushEffects();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.directories).toEqual([
{
address: 'music-posting.bso',
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
nsfw: false,
},
{
address: 'flash.bso',
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
nsfw: true,
},
]);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata).toEqual({
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
});
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('Fresh directories');
expect(persisted.communities).toHaveLength(2);
});
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
expect(latestSnapshot?.directories).toEqual(
expect.arrayContaining([
{
address: 'music-posting.bso',
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
nsfw: false,
},
],
};
]),
);
expect(latestSnapshot?.addresses).toContain('music-posting.bso');
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata?.title).toBe('5chan directories');
fetchMock.mockResolvedValueOnce(createFetchResponse(remotePayload));
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('5chan directories');
expect(persisted.communities.length).toBeGreaterThan(1);
});
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
mockDirectoryListFetches({
mu: {
address: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
features: { safeForWork: true, postsPerPage: 25 },
},
});
renderHarness();
await flushEffects(8);
const firstSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(firstSnapshot.metadata?.title).toBe('Fresh directories');
const firstFetchCount = fetchMock.mock.calls.length;
expect(firstFetchCount).toBeGreaterThan(1);
expect(firstSnapshot.directories.map((community) => community.address)).toContain('music-posting.bso');
expect(firstSnapshot.metadata?.title).toBe('5chan directories');
latestSnapshot = null;
act(() => {
@@ -277,9 +302,43 @@ describe('use-directories', () => {
await flushEffects(8);
const secondSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(secondSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(secondSnapshot.metadata?.title).toBe('Fresh directories');
expect(fetchMock).toHaveBeenCalledTimes(firstFetchCount);
expect(secondSnapshot.directories.map((community) => community.address)).toContain('music-posting.bso');
expect(secondSnapshot.metadata?.title).toBe('5chan directories');
});
it('keeps vendored non-default directories when remote defaults omit them and one list fetch fails', async () => {
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return Promise.resolve(
createFetchResponse(
createRemoteDefaults(['mu'], {
mu: { address: 'music-remote.bso', publicKey: 'music-remote-public-key', title: '/mu/ - Music' },
}),
),
);
}
const code = getDirectoryCodeFromUrl(url);
if (code === 'mu') {
return Promise.resolve(createFetchResponse(createRemoteDirectoryList('mu', { address: 'music-remote.bso', publicKey: 'music-remote-public-key' })));
}
if (code === 'biz') {
return Promise.resolve(createFetchResponse({ error: 'temporary failure' }, false, 500));
}
return Promise.resolve(createFetchResponse({ error: 'missing' }, false, 404));
});
renderHarness('television-and-film.bso');
await flushEffects(12);
const snapshot = expectLatestSnapshot();
const directoriesByCode = new Map(snapshot.directories.map((directory) => [directory.directoryCode, directory]));
expect(directoriesByCode.get('mu')?.address).toBe('music-remote.bso');
expect(directoriesByCode.get('biz')?.address).toBe('business-and-finance.bso');
expect(directoriesByCode.get('tv')?.address).toBe('television-and-film.bso');
expect(snapshot.directory?.address).toBe('television-and-film.bso');
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directory list "biz"'))).toBe(true);
});
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
@@ -290,7 +349,7 @@ describe('use-directories', () => {
renderHarness('unknown.eth');
await flushEffects(8);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
expect(localStorage.getItem(LOCALSTORAGE_KEY)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE_TIMESTAMP_KEY)).toBeNull();
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Invalid directories cache format'))).toBe(true);
@@ -19,8 +19,8 @@ const testState = vi.hoisted(() => ({
list: {
directoryCode: 'biz',
boards: [
{ address: 'business-and-finance.bso', score: 100, managedByDevs: true },
{ address: 'backup-business.bso', score: 10, managedByDevs: false },
{ address: 'business-and-finance.bso', score: 100 },
{ address: 'backup-business.bso', score: 10 },
],
},
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
+1 -1
View File
@@ -22,7 +22,7 @@ export const CommunityStatsCollector = ({ communityAddress }: { communityAddress
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
useEffect(() => {
if (stats && stats.allPostCount !== undefined) {
if (stats && (stats.allPostCount !== undefined || stats.state === 'failed')) {
setCommunityStats(communityAddress, stats);
}
}, [stats, communityAddress, setCommunityStats]);
+125 -98
View File
@@ -1,5 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import directoriesData from '../data/5chan-directories.json';
import directoryListsData from '../data/5chan-directory-lists.json';
import {
directoryListToCommunity,
isRecord,
normalizeDirectoryDefaultsData,
normalizeDirectoryList,
sortDirectoryLists,
toCanonicalCommunity,
toString,
type DirectoriesData,
type DirectoryDefaultsData,
type DirectoryCommunity,
type DirectoryList,
} from '../lib/utils/directory-list-utils';
export type { DirectoriesData, DirectoryCommunity } from '../lib/utils/directory-list-utils';
interface DirectoriesMetadata {
title: string;
@@ -8,47 +23,19 @@ interface DirectoriesMetadata {
updatedAt: number;
}
interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
noSpoilers?: boolean;
noSpoilerReplies?: boolean;
hasFlags?: boolean;
requirePostLink?: boolean;
requirePostLinkIsMedia?: boolean;
[key: string]: unknown;
}
export interface DirectoryCommunity {
title?: string;
address: string;
name?: string;
publicKey?: string;
nsfw?: boolean;
directoryCode?: string;
features?: DirectoryFeatures;
}
export interface DirectoriesData {
title: string;
description: string;
createdAt: number;
updatedAt: number;
communities: DirectoryCommunity[];
}
interface DirectoriesState {
communities: DirectoryCommunity[];
loading: boolean;
error: Error | null;
}
const GITHUB_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories.json';
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-{code}-directory.json';
const GITHUB_DEFAULTS_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-directories-defaults.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
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
const FETCH_TIMEOUT_MS = 10 * 1000;
let cacheCommunities: DirectoryCommunity[] | null = null;
let cacheMetadata: DirectoriesMetadata | null = null;
@@ -67,69 +54,10 @@ export const __resetDirectoriesModuleStateForTests = () => {
fallbackDirectoriesData = null;
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const getStringValue = (...values: unknown[]): string | undefined => values.find((value): value is string => typeof value === 'string' && value.length > 0);
const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalizedFeatures = Object.entries(value).reduce<DirectoryFeatures>((acc, [key, featureValue]) => {
if (typeof featureValue === 'string' || typeof featureValue === 'boolean' || typeof featureValue === 'number') {
acc[key] = featureValue;
}
return acc;
}, {});
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const deriveNsfw = (value: { nsfw?: unknown; features?: { nsfw?: boolean; safeForWork?: boolean } }): boolean | undefined => {
const features = value.features;
const safeForWork = typeof features?.safeForWork === 'boolean' ? features.safeForWork : undefined;
if (safeForWork !== undefined) {
return !safeForWork;
}
const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined;
const topLevelNsfw = typeof (value as { nsfw?: boolean }).nsfw === 'boolean' ? (value as { nsfw: boolean }).nsfw : undefined;
return topLevelNsfw ?? featuresNsfw;
};
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
...new Set([community.address, community.name, community.publicKey].filter((value): value is string => typeof value === 'string' && value.length > 0)),
];
const toCanonicalCommunity = (value: {
address?: unknown;
communityAddress?: unknown;
name?: unknown;
publicKey?: unknown;
title?: unknown;
nsfw?: unknown;
directoryCode?: unknown;
features?: unknown;
}): DirectoryCommunity | null => {
const name = getStringValue(value.name, value.address, value.communityAddress);
if (!name) {
return null;
}
const features = normalizeFeatures(value.features);
const nsfw = deriveNsfw({ ...value, features });
return {
address: name,
name,
...(typeof value.publicKey === 'string' ? { publicKey: value.publicKey } : {}),
...(typeof value.title === 'string' ? { title: value.title } : {}),
...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}),
...(features ? { features } : {}),
...(nsfw !== undefined ? { nsfw } : {}),
};
};
const dedupeCommunities = (entries: DirectoryCommunity[]): DirectoryCommunity[] => {
const seenAddresses = new Set<string>();
const normalizedEntries: DirectoryCommunity[] = [];
@@ -146,6 +74,27 @@ const dedupeCommunities = (entries: DirectoryCommunity[]): DirectoryCommunity[]
return normalizedEntries;
};
const adaptDirectoryLists = (value: Record<string, unknown>): DirectoryCommunity[] => {
if (!Array.isArray(value.directories)) {
return [];
}
const lists = value.directories
.map((directory) => {
if (!isRecord(directory) || !Array.isArray(directory.boards) || typeof directory.directoryCode !== 'string') {
return null;
}
return normalizeDirectoryList(directory, directory.directoryCode);
})
.filter((list): list is DirectoryList => list !== null);
const communities = sortDirectoryLists(lists)
.map(directoryListToCommunity)
.filter((community): community is DirectoryCommunity => community !== null);
return dedupeCommunities(communities);
};
const adaptV2Directories = (value: Record<string, unknown>): DirectoryCommunity[] => {
if (!Array.isArray(value.directories)) {
return [];
@@ -248,14 +197,14 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
return null;
}
const adapters: Array<(raw: Record<string, unknown>) => DirectoryCommunity[]> = [adaptV2Directories, adaptV1Communities];
const adapters: Array<(raw: Record<string, unknown>) => DirectoryCommunity[]> = [adaptDirectoryLists, adaptV2Directories, adaptV1Communities];
const communities = adapters.map((adapter) => adapter(value)).find((normalized) => normalized.length > 0) ?? [];
if (communities.length === 0) {
return null;
}
const fallbackMetadata = getDirectoriesMetadata(directoriesData as unknown);
const fallbackMetadata = getDirectoriesMetadata(directoryListsData as unknown);
return {
title: typeof value.title === 'string' ? value.title : fallbackMetadata.title,
description: typeof value.description === 'string' ? value.description : fallbackMetadata.description,
@@ -269,8 +218,8 @@ let fallbackDirectoriesData: DirectoriesData | null = null;
export const getFallbackDirectoriesData = (): DirectoriesData => {
if (fallbackDirectoriesData) return fallbackDirectoriesData;
const normalized = normalizeDirectoriesData(directoriesData as unknown);
const metadata = getDirectoriesMetadata(directoriesData as unknown);
const normalized = normalizeDirectoriesData(directoryListsData as unknown);
const metadata = getDirectoriesMetadata(directoryListsData as unknown);
fallbackDirectoriesData = normalized ?? {
...metadata,
communities: [],
@@ -322,12 +271,90 @@ const hydrateModuleCaches = (data: DirectoriesData) => {
cacheMetadata = toDirectoriesMetadata(data);
};
const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
const response = await fetch(GITHUB_URL, { cache: 'no-cache' });
const getFallbackDirectoryLists = (defaults?: DirectoryDefaultsData): DirectoryList[] => {
const rawData = directoryListsData as unknown;
if (!isRecord(rawData) || !Array.isArray(rawData.directories)) {
return [];
}
return rawData.directories
.map((directory) => {
if (!isRecord(directory)) {
return null;
}
const fallbackCode = toString(directory.directoryCode);
return fallbackCode ? normalizeDirectoryList(directory, fallbackCode, defaults) : null;
})
.filter((list): list is DirectoryList => list !== null);
};
const fetchWithTimeout = async (url: string): Promise<Response> => {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { cache: 'no-cache', signal: controller.signal });
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Timed out fetching ${url}`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
};
const fetchJsonWithTimeout = async (url: string): Promise<unknown> => {
const response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = normalizeDirectoriesData(await response.json());
return response.json();
};
const fetchDirectoryDefaultsFromGitHub = async (): Promise<DirectoryDefaultsData> => {
return normalizeDirectoryDefaultsData(await fetchJsonWithTimeout(GITHUB_DEFAULTS_URL));
};
const fetchDirectoryListFromGitHub = async (code: string, defaults: DirectoryDefaultsData): Promise<DirectoryList | null> => {
const response = await fetchWithTimeout(GITHUB_URL_TEMPLATE.replace('{code}', code));
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const list = normalizeDirectoryList(await response.json(), code, defaults);
if (!list) {
throw new Error(`Invalid directory list payload for ${code}`);
}
return list;
};
const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
const defaults = await fetchDirectoryDefaultsFromGitHub();
const fallbackLists = getFallbackDirectoryLists(defaults);
const fallbackListsByCode = new Map(fallbackLists.map((list) => [list.directoryCode, list]));
const codes = [...new Set([...Object.keys(defaults.directories), ...fallbackLists.map((list) => list.directoryCode)])];
const fetchedLists = await Promise.all(
codes.map(async (code) => {
try {
return (await fetchDirectoryListFromGitHub(code, defaults)) ?? fallbackListsByCode.get(code) ?? null;
} catch (error) {
console.warn(`Failed to fetch directory list "${code}" from GitHub, using fallback if available:`, error);
return fallbackListsByCode.get(code) ?? null;
}
}),
);
const lists = sortDirectoryLists(fetchedLists.filter((list): list is DirectoryList => list !== null));
const fallbackMetadata = getDirectoriesMetadata(directoryListsData as unknown);
const timestamps = [defaults.createdAt, defaults.updatedAt, ...lists.flatMap((list) => [list.createdAt, list.updatedAt])].filter(
(value): value is number => typeof value === 'number',
);
const data = normalizeDirectoriesData({
...fallbackMetadata,
...(timestamps.length > 0 ? { createdAt: Math.min(...timestamps), updatedAt: Math.max(...timestamps) } : {}),
directories: lists,
});
if (!data) {
throw new Error('Invalid directories payload');
}
+20 -81
View File
@@ -1,25 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { DirectoryCommunity, useDirectories } from './use-directories';
import { type DirectoryList, type DirectoryListBoard, normalizeDirectoryList, sortDirectoryBoardsByRank } from '../lib/utils/directory-list-utils';
export interface DirectoryListBoard {
address: string;
publicKey?: string;
title?: string;
description?: string;
owner?: string;
score: number;
managedByDevs: boolean;
addedAt?: number;
}
interface DirectoryList {
directoryCode: string;
title?: string;
description?: string;
createdAt?: number;
updatedAt?: number;
boards: DirectoryListBoard[];
}
export type { DirectoryListBoard } from '../lib/utils/directory-list-utils';
interface DirectoryListState {
list: DirectoryList | null;
@@ -33,7 +16,7 @@ interface DirectoryListsState {
errorsByCode: Record<string, Error | null>;
}
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-{code}-directory.json';
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
@@ -46,49 +29,6 @@ const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
const lastFetchSuccessAt = new Map<string, number>();
const lastFetchAttemptAt = new Map<string, number>();
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
const toBool = (value: unknown): boolean => value === true;
const normalizeBoard = (raw: unknown): DirectoryListBoard | null => {
if (!isRecord(raw)) return null;
const address = toString(raw.address) ?? toString(raw.name);
if (!address) return null;
return {
address,
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
score: toNumber(raw.score) ?? 0,
managedByDevs: toBool(raw.managedByDevs),
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
};
};
const normalizeDirectoryList = (raw: unknown, fallbackCode: string): DirectoryList | null => {
if (!isRecord(raw)) return null;
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
if (!boardsRaw) return null;
const boards = boardsRaw.map(normalizeBoard).filter((board): board is DirectoryListBoard => board !== null);
if (boards.length === 0) return null;
return {
directoryCode: toString(raw.directoryCode) ?? fallbackCode,
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
};
};
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
const match = directories.find((community) => community.directoryCode === directoryCode);
if (!match || !match.address) return null;
@@ -96,18 +36,24 @@ const synthesizeFromMainDirectory = (directoryCode: string, directories: Directo
const board: DirectoryListBoard = {
address: match.address,
...(match.publicKey ? { publicKey: match.publicKey } : {}),
...(match.title ? { title: match.title } : {}),
score: 0,
managedByDevs: true,
...(match.nsfw !== undefined ? { nsfw: match.nsfw } : {}),
...(match.features ? { features: match.features } : {}),
};
return {
directoryCode,
...(match.title ? { title: match.title } : {}),
...(match.features ? { features: match.features } : {}),
boards: [board],
};
};
const mergeDirectoryListDefaults = (list: DirectoryList, fallback: DirectoryList | null): DirectoryList => ({
...list,
...(list.title || !fallback?.title ? {} : { title: fallback.title }),
...(list.features || !fallback?.features ? {} : { features: fallback.features }),
});
const getLocalStorageKey = (code: string) => `${LOCALSTORAGE_KEY_PREFIX}${code}`;
const getLocalStorageTimestampKey = (code: string) => `${LOCALSTORAGE_TIMESTAMP_KEY_PREFIX}${code}`;
@@ -199,9 +145,9 @@ const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null>
/**
* Fetch the candidate boards for a single directory code (e.g. 'biz').
*
* Source: `bitsocialnet/lists/5chan-{code}-directory.json`. When the network is unavailable
* Source: `bitsocialnet/lists/5chan-directories/5chan-{code}-directory.json`. When the network is unavailable
* or the file is not yet published, falls back to a synthesized single-entry list derived
* from the main 5chan-directories.json (the dev-managed default).
* from the merged directory assignments.
*/
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
const directories = useDirectories();
@@ -227,16 +173,17 @@ export const useDirectoryList = (directoryCode: string | undefined): DirectoryLi
let isMounted = true;
const hydrate = (list: DirectoryList) => {
moduleCaches.set(directoryCode, list);
const mergedList = mergeDirectoryListDefaults(list, fallback);
moduleCaches.set(directoryCode, mergedList);
if (isMounted) {
setState({ list, loading: false, error: null });
setState({ list: mergedList, loading: false, error: null });
}
};
(async () => {
const cached = moduleCaches.get(directoryCode);
if (cached) {
setState({ list: cached, loading: false, error: null });
setState({ list: mergeDirectoryListDefaults(cached, fallback), loading: false, error: null });
} else {
const local = getFromLocalStorage(directoryCode);
if (local) {
@@ -365,16 +312,6 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
return state;
};
/**
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
*/
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
[...boards].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.managedByDevs !== b.managedByDevs) return a.managedByDevs ? -1 : 1;
return (a.addedAt ?? 0) - (b.addedAt ?? 0);
});
/**
* Pick the winning board for a directory, skipping any boards reported offline.
* Returns the highest-ranked online board, or — if every candidate looks offline —
@@ -384,3 +321,5 @@ export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (ad
const ranked = sortDirectoryBoardsByRank(boards);
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
};
export { sortDirectoryBoardsByRank };
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { normalizeDirectoryList, sortDirectoryBoardsByRank } from '../directory-list-utils';
describe('directory-list-utils', () => {
it('preserves board scores and uses them for ranking', () => {
const list = normalizeDirectoryList(
{
directoryCode: 'biz',
boards: [
{ address: 'lower-score.bso', score: 1, addedAt: 1 },
{ address: 'higher-score.bso', score: 10, addedAt: 2 },
],
},
'biz',
);
expect(list?.boards).toEqual([
{ address: 'lower-score.bso', score: 1, addedAt: 1 },
{ address: 'higher-score.bso', score: 10, addedAt: 2 },
]);
expect(sortDirectoryBoardsByRank(list?.boards ?? [])[0]?.address).toBe('higher-score.bso');
});
});
+274
View File
@@ -0,0 +1,274 @@
import { isKnown5chanDeveloper } from './author-display-utils';
interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
noSpoilers?: boolean;
noSpoilerReplies?: boolean;
hasFlags?: boolean;
requirePostLink?: boolean;
requirePostLinkIsMedia?: boolean;
[key: string]: unknown;
}
export interface DirectoryCommunity {
title?: string;
address: string;
name?: string;
publicKey?: string;
nsfw?: boolean;
directoryCode?: string;
features?: DirectoryFeatures;
}
export interface DirectoriesData {
title: string;
description: string;
createdAt: number;
updatedAt: number;
communities: DirectoryCommunity[];
}
export interface DirectoryListBoard {
address: string;
publicKey?: string;
owner?: string;
score?: number;
addedAt?: number;
nsfw?: boolean;
features?: DirectoryFeatures;
}
export interface DirectoryList {
directoryCode: string;
title?: string;
description?: string;
features?: DirectoryFeatures;
createdAt?: number;
updatedAt?: number;
boards: DirectoryListBoard[];
}
interface DirectoryDefaultsEntry {
directoryCode?: string;
title?: string;
features?: DirectoryFeatures;
}
export interface DirectoryDefaultsData {
title?: string;
description?: string;
createdAt?: number;
updatedAt?: number;
directories: Record<string, DirectoryDefaultsEntry>;
}
const DIRECTORY_CODE_ORDER = [
'a',
'co',
'ck',
'pol',
'biz',
'sci',
'g',
'v',
'vg',
'vr',
'fit',
'sp',
'tg',
'adv',
'wsg',
'diy',
'out',
'ic',
'mu',
'int',
'lit',
'his',
'tv',
't',
'x',
'vip',
'gif',
'bant',
'b',
'an',
] as const;
const DIRECTORY_CODE_ORDER_INDEX: ReadonlyMap<string, number> = new Map(DIRECTORY_CODE_ORDER.map((code, index) => [code, index]));
export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
export const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalizedFeatures = Object.entries(value).reduce<DirectoryFeatures>((acc, [key, featureValue]) => {
if (typeof featureValue === 'string' || typeof featureValue === 'boolean' || typeof featureValue === 'number') {
acc[key] = featureValue;
}
return acc;
}, {});
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => {
if (!isRecord(raw)) {
return { directoryCode: code };
}
const directoryCode = toString(raw.directoryCode) ?? code;
const features = normalizeFeatures(raw.features);
return {
directoryCode,
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(features ? { features } : {}),
};
};
export const normalizeDirectoryDefaultsData = (raw: unknown): DirectoryDefaultsData => {
const directoriesRaw = isRecord(raw) && isRecord(raw.directories) ? raw.directories : {};
const directories = Object.fromEntries(Object.entries(directoriesRaw).map(([code, value]) => [code, normalizeDirectoryDefaultsEntry(code, value)]));
return {
...(isRecord(raw) && toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(isRecord(raw) && toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(isRecord(raw) && toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(isRecord(raw) && toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
directories,
};
};
const deriveNsfw = (value: { nsfw?: unknown; features?: { nsfw?: boolean; safeForWork?: boolean } }): boolean | undefined => {
const features = value.features;
const safeForWork = typeof features?.safeForWork === 'boolean' ? features.safeForWork : undefined;
if (safeForWork !== undefined) {
return !safeForWork;
}
const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined;
const topLevelNsfw = typeof value.nsfw === 'boolean' ? value.nsfw : undefined;
return topLevelNsfw ?? featuresNsfw;
};
export const toCanonicalCommunity = (value: {
address?: unknown;
communityAddress?: unknown;
name?: unknown;
publicKey?: unknown;
title?: unknown;
nsfw?: unknown;
directoryCode?: unknown;
features?: unknown;
}): DirectoryCommunity | null => {
const name = toString(value.name) ?? toString(value.address) ?? toString(value.communityAddress);
if (!name) {
return null;
}
const features = normalizeFeatures(value.features);
const nsfw = deriveNsfw({ nsfw: value.nsfw, features });
return {
address: name,
name,
...(typeof value.publicKey === 'string' ? { publicKey: value.publicKey } : {}),
...(typeof value.title === 'string' ? { title: value.title } : {}),
...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}),
...(features ? { features } : {}),
...(nsfw !== undefined ? { nsfw } : {}),
};
};
const normalizeDirectoryListBoard = (raw: unknown): DirectoryListBoard | null => {
if (!isRecord(raw)) return null;
const address = toString(raw.address) ?? toString(raw.name);
if (!address) return null;
const features = normalizeFeatures(raw.features);
const nsfw = deriveNsfw({ nsfw: raw.nsfw, features });
const score = toNumber(raw.score);
return {
address,
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
...(score !== undefined ? { score } : {}),
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
...(features ? { features } : {}),
...(nsfw !== undefined ? { nsfw } : {}),
};
};
export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defaults?: DirectoryDefaultsData): DirectoryList | null => {
if (!isRecord(raw)) return null;
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
if (!boardsRaw) return null;
const boards = boardsRaw.map(normalizeDirectoryListBoard).filter((board): board is DirectoryListBoard => board !== null);
if (boards.length === 0) return null;
const rawCode = toString(raw.directoryCode);
const defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode];
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
return {
directoryCode,
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(features ? { features } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
};
};
export const sortDirectoryLists = (lists: DirectoryList[]): DirectoryList[] =>
[...lists].sort((a, b) => {
const aIndex = DIRECTORY_CODE_ORDER_INDEX.get(a.directoryCode) ?? Number.MAX_SAFE_INTEGER;
const bIndex = DIRECTORY_CODE_ORDER_INDEX.get(b.directoryCode) ?? Number.MAX_SAFE_INTEGER;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return a.directoryCode.localeCompare(b.directoryCode);
});
/**
* Sort boards by future score data when present. Static list ties break in favor
* of known 5chan developer owners, then `addedAt` asc.
* Final tie-break is address for deterministic rendering.
*/
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
[...boards].sort((a, b) => {
if ((b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0);
const aDeveloperOwned = isKnown5chanDeveloper(a.owner);
const bDeveloperOwned = isKnown5chanDeveloper(b.owner);
if (aDeveloperOwned !== bDeveloperOwned) return aDeveloperOwned ? -1 : 1;
if ((a.addedAt ?? Number.MAX_SAFE_INTEGER) !== (b.addedAt ?? Number.MAX_SAFE_INTEGER)) {
return (a.addedAt ?? Number.MAX_SAFE_INTEGER) - (b.addedAt ?? Number.MAX_SAFE_INTEGER);
}
return a.address.localeCompare(b.address);
});
const getPrimaryDirectoryBoard = (boards: DirectoryListBoard[]): DirectoryListBoard | null => sortDirectoryBoardsByRank(boards)[0] ?? null;
export const directoryListToCommunity = (list: DirectoryList): DirectoryCommunity | null => {
const board = getPrimaryDirectoryBoard(list.boards);
if (!board) return null;
return toCanonicalCommunity({
address: board.address,
name: board.address,
publicKey: board.publicKey,
title: list.title,
nsfw: board.nsfw,
directoryCode: list.directoryCode,
features: list.features ?? board.features,
});
};
@@ -17,7 +17,6 @@ const testState = vi.hoisted(() => ({
{
address: 'anime-and-manga.bso',
score: 12,
managedByDevs: false,
},
],
directories: [
@@ -157,7 +156,6 @@ const renderDirectory = async () => {
const createDirectoryBoard = (address: string, score = 12) => ({
address,
score,
managedByDevs: false,
});
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
+2 -2
View File
@@ -145,7 +145,7 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
)}
</td>
<td className={styles.scoreCell}>
<span className={styles.scoreValue}>{board.score}</span>
<span className={styles.scoreValue}>{board.score ?? DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
</td>
<td className={styles.actionsCell}>
[
@@ -166,7 +166,7 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
);
};
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-${directoryCode}-directory.json`;
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-directories/5chan-${directoryCode}-directory.json`;
const Directory = () => {
const { t } = useTranslation();
+6 -6
View File
@@ -70,9 +70,9 @@ const FAQ_SECTIONS: FAQSection[] = [
answer: (
<>
Directories are short paths such as <code>/g/</code>, <code>/biz/</code>, or <code>/mu/</code> that point to featured boards. Today they are handpicked
through pull requests to{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' {...externalLinkProps}>
5chan-directories.json
through pull requests to the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' {...externalLinkProps}>
5chan directory files
</a>{' '}
until directory voting is available. A board does not need a directory slot to exist or be reachable.
</>
@@ -330,9 +330,9 @@ const FAQ_SECTIONS: FAQSection[] = [
question: 'How do I get my board into a directory?',
answer: (
<>
Open a pull request against{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' {...externalLinkProps}>
5chan-directories.json
Open a pull request against the relevant file in the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' {...externalLinkProps}>
5chan-directories folder
</a>{' '}
with the board title, address, and NSFW status. Current expectations include high uptime, active use, relevant topic fit, and responsible moderation. Future
directory voting is planned for 5chan Pass holders.
+15 -1
View File
@@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({
nowSeconds: 1_704_067_210,
navigateMock: vi.fn(),
communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>,
}));
vi.mock('react-i18next', () => ({
@@ -227,6 +227,20 @@ describe('Home', () => {
expect(container.textContent).toContain('boards_tracked 2');
});
it('does not keep aggregate stats loading after a directory stats fetch fails', () => {
testState.communityStats = {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
'tech-posting.eth': { state: 'failed' },
};
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_tracked 2');
});
it('navigates to the canonical board path when the search form is submitted', async () => {
renderHome();
+15 -2
View File
@@ -18,7 +18,7 @@ import lowerCase from 'lodash/lowerCase';
import useCommunitiesLoadingStartTimestamps from '../../stores/use-communities-loading-start-timestamps-store';
import { useNowSeconds } from '../../hooks/use-now-seconds';
// https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json
// https://github.com/bitsocialnet/lists/tree/master/5chan-directories
const SearchBar = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +97,7 @@ interface StatValueProps {
type HomepageStats = {
allPostCount?: number;
weekActiveUserCount?: number;
state?: string;
};
const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value}</> : <LoadingEllipsis string={loadingLabel} />);
@@ -108,6 +109,7 @@ const getDirectoryCode = (directory: DirectoryCommunity): string | null => direc
const getUniqueAddresses = (addresses: string[]): string[] => [...new Set(addresses.filter((address) => address.length > 0))];
const hasLoadedStats = (stat: HomepageStats | undefined): stat is HomepageStats & { allPostCount: number } => stat?.allPostCount !== undefined;
const hasFailedStats = (stat: HomepageStats | undefined): boolean => stat?.state === 'failed';
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
const { t } = useTranslation();
@@ -153,15 +155,26 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
candidateAddresses.forEach((address) => collectorAddressSet.add(address));
let stat: HomepageStats | undefined;
let failedStat: HomepageStats | undefined;
let allCandidateStatsResolved = candidateAddresses.length > 0;
for (const address of candidateAddresses) {
const candidateStats = communitiesStats[address];
if (hasLoadedStats(candidateStats)) {
stat = candidateStats;
break;
}
if (hasFailedStats(candidateStats)) {
failedStat ??= candidateStats;
continue;
}
allCandidateStatsResolved = false;
}
if (!hasLoadedStats(stat)) {
if (!stat && allCandidateStatsResolved) {
stat = failedStat;
}
if (!stat || (!hasLoadedStats(stat) && !hasFailedStats(stat))) {
allDirectoryStatsLoaded = false;
continue;
}
@@ -79,6 +79,7 @@ vi.mock('../../box-modal', () => ({
const directories = [
{ address: 'music-posting.eth', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
{ address: 'tv-posting.eth', title: '/tv/ - Television & Film Directory' },
];
let container: HTMLDivElement;
@@ -158,6 +159,24 @@ describe('PopularThreadsBox', () => {
expect(container.querySelector('.spoilertext')).toBeNull();
});
it('does not show a stale directory suffix in board titles', () => {
testState.popularPosts = [
{
cid: 'thread-tv',
communityAddress: 'tv-posting.eth',
content: 'thread content',
link: 'https://cdn.example/thread-tv.jpg',
thumbnailUrl: 'https://cdn.example/thread-tv-thumb.jpg',
title: '',
},
];
renderPopularThreadsBox();
expect(container.textContent).toContain('Television & Film');
expect(container.textContent).not.toContain('Television & Film Directory');
});
it('subscribes to feed state only while popular threads are loading', () => {
testState.isLoading = true;
testState.popularPosts = [];
@@ -165,6 +184,6 @@ describe('PopularThreadsBox', () => {
renderPopularThreadsBox();
expect(container.textContent).toContain('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth', 'tv-posting.eth']);
});
});
@@ -64,6 +64,8 @@ const PopularThreadCard = memo(
(prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid && prevProps.boardTitle === nextProps.boardTitle && prevProps.boardPath === nextProps.boardPath,
);
const getPopularThreadBoardTitle = (directoryTitle: string | undefined): string => directoryTitle?.replace(/^\/[^/]+\/\s*-\s*/, '').replace(/\s+Directory$/, '') || '';
const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
const { t } = useTranslation();
const showWorksafeContentOnly = usePopularThreadsOptionsStore((state) => state.showWorksafeContentOnly);
@@ -103,7 +105,7 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
popularPosts.map((post: Comment) => {
const communityAddress = getCommentCommunityAddress(post);
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const boardTitle = directoryEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
const boardTitle = getPopularThreadBoardTitle(directoryEntry?.title);
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : '';
return <PopularThreadCard key={post.cid} post={post} boardTitle={boardTitle} boardPath={boardPath} />;
})