From 45a6ff0bab5bb26996ee96d3aaf1d3d6f50ea701 Mon Sep 17 00:00:00 2001 From: plebeius Date: Tue, 17 Feb 2026 17:35:53 +0800 Subject: [PATCH] feat(directories): implement features.noSpoilers and features.noSpoilerReplies for board-specific spoiler checkbox --- src/components/post-form/post-form.tsx | 36 +++++++----- src/components/reply-modal/reply-modal.tsx | 21 ++++--- src/hooks/use-directories.ts | 65 ++++++++++++++++------ 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 9b87de21..ac976500 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -9,7 +9,7 @@ import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import { useDirectories } from '../../hooks/use-directories'; +import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; @@ -58,6 +58,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const resolvedAddress = useResolvedSubplebbitAddress(); const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress; const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress }); + const effectiveBoardAddress = subplebbitAddress || publishPostOptions.subplebbitAddress; const textRef = useRef(null); const urlRef = useRef(null); @@ -69,6 +70,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const subscriptions = account?.subscriptions || []; const directories = useDirectories(); + const directoryEntry = useDirectoryByAddress(effectiveBoardAddress); + const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true; + const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const { accountSubplebbits } = useAccountSubplebbits(); const accountSubplebbitAddresses = Object.keys(accountSubplebbits); @@ -294,20 +298,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: {isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')} - - {t('options')} - - [ - - ] - - + {((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && ( + + {t('options')} + + [ + + ] + + + )} {(isInAllView || isInSubscriptionsView || isInModView) && ( {t('board')} diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 76c84bf7..43267a3f 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -9,6 +9,7 @@ import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useSelectedTextStore from '../../stores/use-selected-text-store'; import useReplyModalStore from '../../stores/use-reply-modal-store'; +import { useDirectoryByAddress } from '../../hooks/use-directories'; import usePublishReply from '../../hooks/use-publish-reply'; import useIsMobile from '../../hooks/use-is-mobile'; import { useFileUpload } from '../../hooks/use-file-upload'; @@ -30,6 +31,8 @@ interface ReplyModalProps { const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => { const { t } = useTranslation(); + const directoryEntry = useDirectoryByAddress(subplebbitAddress); + const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({ cid: parentCid, subplebbitAddress, @@ -343,14 +346,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa {isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')} - - [ - - ] - + {showSpoilerForReply && ( + + [ + + ] + + )} diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts index 8aea64bf..0e01d103 100644 --- a/src/hooks/use-directories.ts +++ b/src/hooks/use-directories.ts @@ -11,6 +11,8 @@ export interface DirectoriesMetadata { export interface DirectoryFeatures { pseudonymityMode?: string; nsfw?: boolean; + noSpoilers?: boolean; + noSpoilerReplies?: boolean; hasFlags?: boolean; requirePostLink?: boolean; requirePostLinkIsMedia?: boolean; @@ -43,7 +45,6 @@ const GITHUB_URL = 'https://raw.githubusercontent.com/bitsocialhq/lists/master/5 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 FALLBACK_DIRECTORIES_DATA = directoriesData as DirectoriesData; let cacheCommunities: DirectoryCommunity[] | null = null; let cacheMetadata: DirectoriesMetadata | null = null; @@ -66,21 +67,31 @@ const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => { return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined; }; -const toCanonicalCommunity = (value: { address: unknown; title: unknown; nsfw: unknown; directoryCode?: unknown; features?: unknown }): DirectoryCommunity | null => { +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 toCanonicalCommunity = (value: { address: unknown; title?: unknown; nsfw?: unknown; directoryCode?: unknown; features?: unknown }): DirectoryCommunity | null => { if (typeof value.address !== 'string') { return null; } const features = normalizeFeatures(value.features); - const topLevelNsfw = typeof value.nsfw === 'boolean' ? value.nsfw : undefined; - const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined; + const nsfw = deriveNsfw({ ...value, features }); return { address: value.address, ...(typeof value.title === 'string' ? { title: value.title } : {}), ...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}), ...(features ? { features } : {}), - ...((topLevelNsfw ?? featuresNsfw) !== undefined ? { nsfw: topLevelNsfw ?? featuresNsfw } : {}), + ...(nsfw !== undefined ? { nsfw } : {}), }; }; @@ -113,7 +124,6 @@ const adaptV2Directories = (value: Record): DirectoryCommunity[ return toCanonicalCommunity({ address: directory.communityAddress, title: directory.title, - nsfw: features?.nsfw, directoryCode: directory.directoryCode, features, }); @@ -158,15 +168,33 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => { return null; } + const raw = directoriesData as DirectoriesData; return { - title: typeof value.title === 'string' ? value.title : FALLBACK_DIRECTORIES_DATA.title, - description: typeof value.description === 'string' ? value.description : FALLBACK_DIRECTORIES_DATA.description, - createdAt: typeof value.createdAt === 'number' ? value.createdAt : FALLBACK_DIRECTORIES_DATA.createdAt, - updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : FALLBACK_DIRECTORIES_DATA.updatedAt, + title: typeof value.title === 'string' ? value.title : (raw.title ?? ''), + description: typeof value.description === 'string' ? value.description : (raw.description ?? ''), + createdAt: typeof value.createdAt === 'number' ? value.createdAt : (raw.createdAt ?? 0), + updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : (raw.updatedAt ?? 0), communities, }; }; +let fallbackDirectoriesData: DirectoriesData | null = null; + +const getFallbackDirectoriesData = (): DirectoriesData => { + if (fallbackDirectoriesData) return fallbackDirectoriesData; + const normalized = normalizeDirectoriesData(directoriesData as unknown); + fallbackDirectoriesData = + normalized ?? + ({ + title: (directoriesData as DirectoriesData).title ?? '', + description: (directoriesData as DirectoriesData).description ?? '', + createdAt: (directoriesData as DirectoriesData).createdAt ?? 0, + updatedAt: (directoriesData as DirectoriesData).updatedAt ?? 0, + communities: (directoriesData as DirectoriesData).communities ?? [], + } as DirectoriesData); + return fallbackDirectoriesData; +}; + const getFromLocalStorage = (): DirectoriesData | null => { try { const cached = localStorage.getItem(LOCALSTORAGE_KEY); @@ -226,7 +254,7 @@ export const useDirectories = () => { // Use vendored data as initial state to prevent theme flash on first load // This ensures NSFW status is known synchronously before first render const [state, setState] = useState({ - communities: FALLBACK_DIRECTORIES_DATA.communities, + communities: getFallbackDirectoriesData().communities, loading: true, error: null, }); @@ -267,7 +295,7 @@ export const useDirectories = () => { console.warn('Failed to fetch directories from GitHub:', e); // Only fall back if we don't already have memory/localStorage data if (!cacheCommunities) { - hydrateCommunities(FALLBACK_DIRECTORIES_DATA); + hydrateCommunities(getFallbackDirectoriesData()); } } })(); @@ -280,13 +308,13 @@ export const useDirectories = () => { // Always prefer cacheCommunities (module-level, stable reference) when available // Only use state.communities during initial load before cache is populated // This ensures a stable reference for memoization in consuming hooks - return cacheCommunities || state.communities || FALLBACK_DIRECTORIES_DATA.communities; + return cacheCommunities || state.communities || getFallbackDirectoriesData().communities; }; export const useDirectoriesState = () => { // Use vendored data as fallback to prevent theme flash on first load const [state, setState] = useState({ - communities: cacheCommunities || FALLBACK_DIRECTORIES_DATA.communities, + communities: cacheCommunities || getFallbackDirectoriesData().communities, loading: !cacheCommunities, error: null, }); @@ -327,7 +355,7 @@ export const useDirectoriesState = () => { console.warn('Failed to fetch directories from GitHub:', e); // Only fall back if we don't already have memory/localStorage data if (!cacheCommunities) { - hydrateCommunities(FALLBACK_DIRECTORIES_DATA); + hydrateCommunities(getFallbackDirectoriesData()); } } })(); @@ -345,6 +373,11 @@ export const useDirectoryAddresses = () => { return useMemo(() => (Array.isArray(directories) ? directories.map((community) => community.address) : []), [directories]); }; +export const useDirectoryByAddress = (address: string | undefined) => { + const directories = useDirectories(); + return useMemo(() => (address ? directories.find((c) => c.address === address) : undefined), [directories, address]); +}; + export const useDirectoriesMetadata = () => { const [metadata, setMetadata] = useState(null); @@ -382,7 +415,7 @@ export const useDirectoriesMetadata = () => { console.warn('Failed to fetch directory metadata from GitHub:', e); // Only fall back if we don't already have memory/localStorage data if (!cacheMetadata) { - hydrateMetadata(FALLBACK_DIRECTORIES_DATA); + hydrateMetadata(getFallbackDirectoriesData()); } } })();