mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(directories): implement features.noSpoilers and features.noSpoilerReplies for board-specific spoiler checkbox
This commit is contained in:
@@ -9,7 +9,7 @@ import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
|||||||
import { formatMarkdown } from '../../lib/utils/post-utils';
|
import { formatMarkdown } from '../../lib/utils/post-utils';
|
||||||
import { isValidURL } from '../../lib/utils/url-utils';
|
import { isValidURL } from '../../lib/utils/url-utils';
|
||||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-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 { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||||
@@ -58,6 +58,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const resolvedAddress = useResolvedSubplebbitAddress();
|
const resolvedAddress = useResolvedSubplebbitAddress();
|
||||||
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
|
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
|
||||||
const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress });
|
const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress });
|
||||||
|
const effectiveBoardAddress = subplebbitAddress || publishPostOptions.subplebbitAddress;
|
||||||
|
|
||||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -69,6 +70,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||||
const subscriptions = account?.subscriptions || [];
|
const subscriptions = account?.subscriptions || [];
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
|
const directoryEntry = useDirectoryByAddress(effectiveBoardAddress);
|
||||||
|
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
||||||
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
|
|
||||||
const { accountSubplebbits } = useAccountSubplebbits();
|
const { accountSubplebbits } = useAccountSubplebbits();
|
||||||
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
||||||
@@ -294,20 +298,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className={styles.spoilerButton}>
|
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||||
<td>{t('options')}</td>
|
<tr className={styles.spoilerButton}>
|
||||||
<td>
|
<td>{t('options')}</td>
|
||||||
[
|
<td>
|
||||||
<label>
|
[
|
||||||
<input
|
<label>
|
||||||
type='checkbox'
|
<input
|
||||||
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
type='checkbox'
|
||||||
/>
|
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
||||||
{capitalize(t('spoiler'))}?
|
/>
|
||||||
</label>
|
{capitalize(t('spoiler'))}?
|
||||||
]
|
</label>
|
||||||
</td>
|
]
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{(isInAllView || isInSubscriptionsView || isInModView) && (
|
{(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t('board')}</td>
|
<td>{t('board')}</td>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { isValidURL } from '../../lib/utils/url-utils';
|
|||||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
|
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||||
import usePublishReply from '../../hooks/use-publish-reply';
|
import usePublishReply from '../../hooks/use-publish-reply';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
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 ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const directoryEntry = useDirectoryByAddress(subplebbitAddress);
|
||||||
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({
|
const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({
|
||||||
cid: parentCid,
|
cid: parentCid,
|
||||||
subplebbitAddress,
|
subplebbitAddress,
|
||||||
@@ -343,14 +346,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
|
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.spoilerButton}>
|
{showSpoilerForReply && (
|
||||||
[
|
<span className={styles.spoilerButton}>
|
||||||
<label>
|
[
|
||||||
<input type='checkbox' onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
<label>
|
||||||
{capitalize(t('spoiler'))}?
|
<input type='checkbox' onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
||||||
</label>
|
{capitalize(t('spoiler'))}?
|
||||||
]
|
</label>
|
||||||
</span>
|
]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button className={styles.publishButton} onClick={onPublishReply}>
|
<button className={styles.publishButton} onClick={onPublishReply}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface DirectoriesMetadata {
|
|||||||
export interface DirectoryFeatures {
|
export interface DirectoryFeatures {
|
||||||
pseudonymityMode?: string;
|
pseudonymityMode?: string;
|
||||||
nsfw?: boolean;
|
nsfw?: boolean;
|
||||||
|
noSpoilers?: boolean;
|
||||||
|
noSpoilerReplies?: boolean;
|
||||||
hasFlags?: boolean;
|
hasFlags?: boolean;
|
||||||
requirePostLink?: boolean;
|
requirePostLink?: boolean;
|
||||||
requirePostLinkIsMedia?: 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_KEY = '5chan-directories-cache';
|
||||||
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
|
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
|
||||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const FALLBACK_DIRECTORIES_DATA = directoriesData as DirectoriesData;
|
|
||||||
|
|
||||||
let cacheCommunities: DirectoryCommunity[] | null = null;
|
let cacheCommunities: DirectoryCommunity[] | null = null;
|
||||||
let cacheMetadata: DirectoriesMetadata | 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;
|
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') {
|
if (typeof value.address !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const features = normalizeFeatures(value.features);
|
const features = normalizeFeatures(value.features);
|
||||||
const topLevelNsfw = typeof value.nsfw === 'boolean' ? value.nsfw : undefined;
|
const nsfw = deriveNsfw({ ...value, features });
|
||||||
const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
address: value.address,
|
address: value.address,
|
||||||
...(typeof value.title === 'string' ? { title: value.title } : {}),
|
...(typeof value.title === 'string' ? { title: value.title } : {}),
|
||||||
...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}),
|
...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}),
|
||||||
...(features ? { features } : {}),
|
...(features ? { features } : {}),
|
||||||
...((topLevelNsfw ?? featuresNsfw) !== undefined ? { nsfw: topLevelNsfw ?? featuresNsfw } : {}),
|
...(nsfw !== undefined ? { nsfw } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,7 +124,6 @@ const adaptV2Directories = (value: Record<string, unknown>): DirectoryCommunity[
|
|||||||
return toCanonicalCommunity({
|
return toCanonicalCommunity({
|
||||||
address: directory.communityAddress,
|
address: directory.communityAddress,
|
||||||
title: directory.title,
|
title: directory.title,
|
||||||
nsfw: features?.nsfw,
|
|
||||||
directoryCode: directory.directoryCode,
|
directoryCode: directory.directoryCode,
|
||||||
features,
|
features,
|
||||||
});
|
});
|
||||||
@@ -158,15 +168,33 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const raw = directoriesData as DirectoriesData;
|
||||||
return {
|
return {
|
||||||
title: typeof value.title === 'string' ? value.title : FALLBACK_DIRECTORIES_DATA.title,
|
title: typeof value.title === 'string' ? value.title : (raw.title ?? ''),
|
||||||
description: typeof value.description === 'string' ? value.description : FALLBACK_DIRECTORIES_DATA.description,
|
description: typeof value.description === 'string' ? value.description : (raw.description ?? ''),
|
||||||
createdAt: typeof value.createdAt === 'number' ? value.createdAt : FALLBACK_DIRECTORIES_DATA.createdAt,
|
createdAt: typeof value.createdAt === 'number' ? value.createdAt : (raw.createdAt ?? 0),
|
||||||
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : FALLBACK_DIRECTORIES_DATA.updatedAt,
|
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : (raw.updatedAt ?? 0),
|
||||||
communities,
|
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 => {
|
const getFromLocalStorage = (): DirectoriesData | null => {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(LOCALSTORAGE_KEY);
|
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
|
// Use vendored data as initial state to prevent theme flash on first load
|
||||||
// This ensures NSFW status is known synchronously before first render
|
// This ensures NSFW status is known synchronously before first render
|
||||||
const [state, setState] = useState<DirectoriesState>({
|
const [state, setState] = useState<DirectoriesState>({
|
||||||
communities: FALLBACK_DIRECTORIES_DATA.communities,
|
communities: getFallbackDirectoriesData().communities,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -267,7 +295,7 @@ export const useDirectories = () => {
|
|||||||
console.warn('Failed to fetch directories from GitHub:', e);
|
console.warn('Failed to fetch directories from GitHub:', e);
|
||||||
// Only fall back if we don't already have memory/localStorage data
|
// Only fall back if we don't already have memory/localStorage data
|
||||||
if (!cacheCommunities) {
|
if (!cacheCommunities) {
|
||||||
hydrateCommunities(FALLBACK_DIRECTORIES_DATA);
|
hydrateCommunities(getFallbackDirectoriesData());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -280,13 +308,13 @@ export const useDirectories = () => {
|
|||||||
// Always prefer cacheCommunities (module-level, stable reference) when available
|
// Always prefer cacheCommunities (module-level, stable reference) when available
|
||||||
// Only use state.communities during initial load before cache is populated
|
// Only use state.communities during initial load before cache is populated
|
||||||
// This ensures a stable reference for memoization in consuming hooks
|
// 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 = () => {
|
export const useDirectoriesState = () => {
|
||||||
// Use vendored data as fallback to prevent theme flash on first load
|
// Use vendored data as fallback to prevent theme flash on first load
|
||||||
const [state, setState] = useState<DirectoriesState>({
|
const [state, setState] = useState<DirectoriesState>({
|
||||||
communities: cacheCommunities || FALLBACK_DIRECTORIES_DATA.communities,
|
communities: cacheCommunities || getFallbackDirectoriesData().communities,
|
||||||
loading: !cacheCommunities,
|
loading: !cacheCommunities,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -327,7 +355,7 @@ export const useDirectoriesState = () => {
|
|||||||
console.warn('Failed to fetch directories from GitHub:', e);
|
console.warn('Failed to fetch directories from GitHub:', e);
|
||||||
// Only fall back if we don't already have memory/localStorage data
|
// Only fall back if we don't already have memory/localStorage data
|
||||||
if (!cacheCommunities) {
|
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]);
|
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 = () => {
|
export const useDirectoriesMetadata = () => {
|
||||||
const [metadata, setMetadata] = useState<DirectoriesMetadata | null>(null);
|
const [metadata, setMetadata] = useState<DirectoriesMetadata | null>(null);
|
||||||
|
|
||||||
@@ -382,7 +415,7 @@ export const useDirectoriesMetadata = () => {
|
|||||||
console.warn('Failed to fetch directory metadata from GitHub:', e);
|
console.warn('Failed to fetch directory metadata from GitHub:', e);
|
||||||
// Only fall back if we don't already have memory/localStorage data
|
// Only fall back if we don't already have memory/localStorage data
|
||||||
if (!cacheMetadata) {
|
if (!cacheMetadata) {
|
||||||
hydrateMetadata(FALLBACK_DIRECTORIES_DATA);
|
hydrateMetadata(getFallbackDirectoriesData());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user