feat(settings): add media hosting settings and provider-based upload gating

This commit is contained in:
plebeius
2026-02-17 18:57:54 +08:00
parent 45a6ff0bab
commit 96f11b3e26
46 changed files with 448 additions and 55 deletions
+14 -9
View File
@@ -16,6 +16,7 @@ import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import styles from './post-form.module.css';
import { capitalize, debounce } from 'lodash';
@@ -209,6 +210,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
},
});
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const showUploadControls = selectedProvider !== 'none';
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -289,15 +292,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
</td>
</tr>
<tr className={styles.uploadButton}>
<td>{t('file')}</td>
<td>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
</td>
</tr>
{showUploadControls && (
<tr className={styles.uploadButton}>
<td>{t('file')}</td>
<td>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
</td>
</tr>
)}
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
<tr className={styles.spoilerButton}>
<td>{t('options')}</td>
+14 -9
View File
@@ -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 useMediaHostingStore from '../../stores/use-media-hosting-store';
import { useDirectoryByAddress } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
@@ -271,6 +272,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
},
});
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const showUploadControls = selectedProvider !== 'none';
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -336,16 +339,18 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
/>
</div>
<div className={styles.footer}>
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
{showUploadControls && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
</span>
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
</span>
</span>
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
</span>
</span>
)}
{showSpoilerForReply && (
<span className={styles.spoilerButton}>
[
@@ -22,6 +22,17 @@
text-decoration: var(--post-content-link-text-decoration-hover);
}
.setting a {
color: var(--post-link-text-color);
text-decoration: var(--post-content-link-text-decoration);
text-transform: lowercase;
}
.setting a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-content-link-text-decoration-hover);
}
.interfaceSettings {
margin-bottom: 10px;
}
@@ -30,6 +41,10 @@
margin-right: 2px;
}
.setting input[type="radio"] {
margin-right: 5px;
}
.settingTip {
font-size: 0.85em;
margin: 2px 0 5px 0;
@@ -0,0 +1,92 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MediaHostingSettings from '../media-hosting-settings';
import { MEDIA_HOSTING_PROVIDERS } from '../../../../stores/use-media-hosting-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mockSetSelectedProvider = vi.fn();
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
vi.mock('../../../../stores/use-media-hosting-store', async (importOriginal) => {
const mod = await importOriginal<typeof import('../../../../stores/use-media-hosting-store')>();
return {
...mod,
default: (selector: (state: { selectedProvider: string; setSelectedProvider: (provider: string) => void }) => unknown) =>
selector({
selectedProvider: selectedProviderRef.value,
setSelectedProvider: mockSetSelectedProvider,
}),
};
});
let root: Root;
let container: HTMLDivElement;
const render = () => {
act(() => {
root.render(createElement(MediaHostingSettings));
});
};
describe('MediaHostingSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
selectedProviderRef.value = 'catbox';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders None option and all providers', () => {
render();
const radios = container.querySelectorAll('input[type="radio"]');
expect(radios.length).toBe(MEDIA_HOSTING_PROVIDERS.length + 1);
expect(container.textContent).toContain('media_hosting_none');
for (const provider of MEDIA_HOSTING_PROVIDERS) {
expect(container.textContent).toContain(provider.name);
expect(container.textContent).toContain(provider.url);
}
});
it('default selected is catbox', () => {
render();
const catboxRadio = container.querySelector<HTMLInputElement>('input[value="catbox"]');
expect(catboxRadio).not.toBeNull();
expect(catboxRadio?.checked).toBe(true);
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
expect(noneRadio?.checked).toBe(false);
});
it('clicking a radio updates store selection', async () => {
render();
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
expect(noneRadio).not.toBeNull();
await act(async () => {
noneRadio?.click();
});
expect(mockSetSelectedProvider).toHaveBeenCalledWith('none');
mockSetSelectedProvider.mockClear();
selectedProviderRef.value = 'none';
render();
const providerRadio = container.querySelector<HTMLInputElement>(`input[value="${MEDIA_HOSTING_PROVIDERS[0].id}"]`);
expect(providerRadio).not.toBeNull();
await act(async () => {
providerRadio?.click();
});
expect(mockSetSelectedProvider).toHaveBeenCalledWith(MEDIA_HOSTING_PROVIDERS[0].id);
});
});
@@ -0,0 +1 @@
export { default } from './media-hosting-settings';
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store';
import styles from '../interface-settings/interface-settings.module.css';
const RADIO_NAME = 'media-hosting-provider';
const MediaHostingSettings = () => {
const { t } = useTranslation();
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const setSelectedProvider = useMediaHostingStore((state) => state.setSelectedProvider);
return (
<div className={styles.interfaceSettings}>
<div role='radiogroup' aria-label={t('media_hosting')}>
{MEDIA_HOSTING_PROVIDERS.map((provider) => (
<div key={provider.id} className={styles.setting}>
<label>
<input type='radio' name={RADIO_NAME} value={provider.id} checked={selectedProvider === provider.id} onChange={() => setSelectedProvider(provider.id)} />
{provider.name} (
<a href={provider.url} target='_blank' rel='noopener noreferrer'>
{provider.url}
</a>
)
</label>
</div>
))}
<div className={styles.setting}>
<label>
<input type='radio' name={RADIO_NAME} value='none' checked={selectedProvider === 'none'} onChange={() => setSelectedProvider('none')} />
{t('media_hosting_none')}
</label>
<div className={styles.settingTip}>{t('media_hosting_none_tip')}</div>
</div>
</div>
</div>
);
};
export default MediaHostingSettings;
@@ -8,6 +8,7 @@ import BlockedAddressesSetting from './blocked-addresses-setting';
import CryptoAddressSetting from './crypto-address-setting';
import CryptoWalletsSetting from './crypto-wallets-setting';
import InterfaceSettings from './interface-settings';
import MediaHostingSettings from './media-hosting-settings';
import P2pOptions from './p2p-options';
import SubscriptionsSetting from './subscriptions-setting';
@@ -36,6 +37,7 @@ const SettingsModal = () => {
}, [closeModal]);
const [showInterfaceSettings, setShowInterfaceSettings] = useState(false);
const [showMediaHostingSettings, setShowMediaHostingSettings] = useState(false);
const [showAccountSettings, setShowAccountSettings] = useState(false);
const [showAvatarSettings, setShowAvatarSettings] = useState(false);
const [showCryptoAddressSetting, setShowCryptoAddressSetting] = useState(false);
@@ -48,6 +50,7 @@ const SettingsModal = () => {
const getExpandedCount = () => {
return (
Number(showInterfaceSettings) +
Number(showMediaHostingSettings) +
Number(showAccountSettings) +
Number(showAvatarSettings) +
Number(showCryptoAddressSetting) +
@@ -60,6 +63,7 @@ const SettingsModal = () => {
const getExpandedCategoryId = (excludeCategoryId?: string) => {
if (showInterfaceSettings && 'interface-settings' !== excludeCategoryId) return 'interface-settings';
if (showMediaHostingSettings && 'media-hosting-settings' !== excludeCategoryId) return 'media-hosting-settings';
if (showAccountSettings && 'account-settings' !== excludeCategoryId) return 'account-settings';
if (showAvatarSettings && 'avatar-settings' !== excludeCategoryId) return 'avatar-settings';
if (showCryptoAddressSetting && 'crypto-address-settings' !== excludeCategoryId) return 'crypto-address-settings';
@@ -100,6 +104,7 @@ const SettingsModal = () => {
useEffect(() => {
if (hash) {
setShowInterfaceSettings(hash === 'interface-settings');
setShowMediaHostingSettings(hash === 'media-hosting-settings');
setShowAccountSettings(hash === 'account-settings');
setShowAvatarSettings(hash === 'avatar-settings');
setShowCryptoAddressSetting(hash === 'crypto-address-settings');
@@ -114,6 +119,7 @@ const SettingsModal = () => {
const newExpandState = !expandAll;
setExpandAll(newExpandState);
setShowInterfaceSettings(newExpandState);
setShowMediaHostingSettings(newExpandState);
setShowAccountSettings(newExpandState);
setShowAvatarSettings(newExpandState);
setShowCryptoAddressSetting(newExpandState);
@@ -144,6 +150,13 @@ const SettingsModal = () => {
</label>
</div>
{showInterfaceSettings && <InterfaceSettings />}
<div id='media-hosting-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('media-hosting-settings', showMediaHostingSettings, setShowMediaHostingSettings)}>
<span className={showMediaHostingSettings ? styles.hideButton : styles.showButton} />
{t('media_hosting')}
</label>
</div>
{showMediaHostingSettings && <MediaHostingSettings />}
<div id='account-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('account-settings', showAccountSettings, setShowAccountSettings)}>
<span className={showAccountSettings ? styles.hideButton : styles.showButton} />
@@ -27,6 +27,11 @@ vi.mock('../../lib/utils/catbox-utils', () => ({
uploadToCatbox: vi.fn(),
}));
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
vi.mock('../../stores/use-media-hosting-store', () => ({
default: (selector: (s: { selectedProvider: string }) => unknown) => selector({ selectedProvider: selectedProviderRef.value }),
}));
type HookSnapshot = ReturnType<typeof useFileUpload>;
let latestHook: HookSnapshot | null = null;
@@ -68,6 +73,7 @@ const selectFileFromHiddenInput = async (file: File | null) => {
describe('useFileUpload', () => {
beforeEach(() => {
vi.clearAllMocks();
selectedProviderRef.value = 'catbox';
latestHook = null;
container = document.createElement('div');
document.body.append(container);
@@ -177,4 +183,39 @@ describe('useFileUpload', () => {
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('returns early when selectedProvider is none (no upload, no alert)', async () => {
selectedProviderRef.value = 'none';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
vi.mocked(FileUploader.pickAndUploadMedia).mockResolvedValue({
url: 'https://files.catbox.moe/skip.jpg',
fileName: 'skip.jpg',
});
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(FileUploader.pickAndUploadMedia).not.toHaveBeenCalled();
expect(window.alert).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('returns early when selectedProvider is unknown (no crash, no upload)', async () => {
selectedProviderRef.value = 'unknown-provider';
vi.mocked(Capacitor.getPlatform).mockReturnValue('ios');
window.electronApi = { isElectron: true } as any;
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(uploadToCatbox).not.toHaveBeenCalled();
expect(window.alert).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
});
+12 -2
View File
@@ -3,9 +3,15 @@ import { Capacitor } from '@capacitor/core';
import { useTranslation } from 'react-i18next';
import FileUploader from '../plugins/file-uploader';
import { uploadToCatbox } from '../lib/utils/catbox-utils';
import useMediaHostingStore from '../stores/use-media-hosting-store';
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
async function uploadByProvider(provider: string, file: File): Promise<string> {
if (provider === 'catbox') return uploadToCatbox(file);
throw new Error(`Unsupported media provider: ${provider}`);
}
export interface UseFileUploadOptions {
onUploadComplete: (url: string, fileName: string) => void;
}
@@ -54,10 +60,14 @@ function selectFileViaInput(): Promise<File | null> {
export function useFileUpload(options: UseFileUploadOptions) {
const { t } = useTranslation();
const { onUploadComplete } = options;
const selectedProvider = useMediaHostingStore((s) => s.selectedProvider);
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const handleUpload = useCallback(async () => {
if (selectedProvider === 'none') return;
if (selectedProvider !== 'catbox') return;
try {
setIsUploading(true);
setUploadedFileName(null);
@@ -79,7 +89,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
}
const url = await uploadToCatbox(file);
const url = await uploadByProvider(selectedProvider, file);
setUploadedFileName(file.name);
onUploadComplete(url, file.name);
return;
@@ -94,7 +104,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
} finally {
setIsUploading(false);
}
}, [onUploadComplete, t]);
}, [onUploadComplete, t, selectedProvider]);
return {
isUploading,
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../use-media-hosting-store';
const STORAGE_KEY = 'media-hosting-storage';
describe('useMediaHostingStore', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
});
afterEach(() => {
setItemSpy.mockRestore();
useMediaHostingStore.getState().setSelectedProvider('catbox');
});
it('exports MEDIA_HOSTING_PROVIDERS with at least Catbox provider', () => {
expect(MEDIA_HOSTING_PROVIDERS).toBeDefined();
expect(MEDIA_HOSTING_PROVIDERS.length).toBeGreaterThanOrEqual(1);
const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox');
expect(catbox).toEqual({ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' });
});
it('defaults selectedProvider to catbox', () => {
const { selectedProvider } = useMediaHostingStore.getState();
expect(selectedProvider).toBe('catbox');
});
it('sets selectedProvider to none when setSelectedProvider is called with none', () => {
useMediaHostingStore.getState().setSelectedProvider('none');
const { selectedProvider } = useMediaHostingStore.getState();
expect(selectedProvider).toBe('none');
});
it('persists state to localStorage when setSelectedProvider is called', () => {
useMediaHostingStore.getState().setSelectedProvider('none');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"selectedProvider":"none"'));
});
});
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const MEDIA_HOSTING_PROVIDERS = [{ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' }] as const;
export type MediaHostingSelection = (typeof MEDIA_HOSTING_PROVIDERS)[number]['id'] | 'none' | string;
interface MediaHostingStore {
selectedProvider: MediaHostingSelection;
setSelectedProvider: (provider: string) => void;
}
const useMediaHostingStore = create<MediaHostingStore>()(
persist(
(set) => ({
selectedProvider: 'catbox',
setSelectedProvider: (provider) => set({ selectedProvider: provider }),
}),
{
name: 'media-hosting-storage',
},
),
);
export default useMediaHostingStore;