mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(media-hosting): add multi-provider configurable upload with fallback
Add Random/Preferred/None modes, preferred provider selection, and provider-order fallback. Catbox/Imgur/PostImages on Android via WebView; Electron CDP automation for Imgur/PostImages. Hide upload controls on web runtime.
This commit is contained in:
@@ -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 { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||
import styles from './post-form.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
@@ -211,8 +212,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}
|
||||
},
|
||||
});
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const showUploadControls = selectedProvider !== 'none';
|
||||
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||
|
||||
const hasInitializedDisplayName = useRef(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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 { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
@@ -275,8 +276,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}
|
||||
},
|
||||
});
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const showUploadControls = selectedProvider !== 'none';
|
||||
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||
|
||||
const hasInitializedDisplayName = useRef(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -50,3 +50,20 @@
|
||||
margin: 2px 0 5px 0;
|
||||
padding-left: 19px;
|
||||
}
|
||||
|
||||
.webUploadWarning {
|
||||
font-size: 0.8em;
|
||||
padding: 5px 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.webUploadWarning a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-content-link-text-decoration);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.webUploadWarning a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-content-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
+129
-27
@@ -12,16 +12,27 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mockSetSelectedProvider = vi.fn();
|
||||
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
|
||||
const mockGetPlatform = vi.fn(() => 'web');
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: { getPlatform: () => mockGetPlatform() },
|
||||
}));
|
||||
|
||||
const mockSetUploadMode = vi.fn();
|
||||
const mockSetPreferredProvider = vi.fn();
|
||||
const uploadModeRef = vi.hoisted(() => ({ value: 'random' as 'random' | 'preferred' | 'none' }));
|
||||
const preferredProviderRef = vi.hoisted(() => ({
|
||||
value: 'catbox' as 'catbox' | 'imgur' | 'postimages',
|
||||
}));
|
||||
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) =>
|
||||
default: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
selectedProvider: selectedProviderRef.value,
|
||||
setSelectedProvider: mockSetSelectedProvider,
|
||||
uploadMode: uploadModeRef.value,
|
||||
preferredProvider: preferredProviderRef.value,
|
||||
setUploadMode: mockSetUploadMode,
|
||||
setPreferredProvider: mockSetPreferredProvider,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -38,7 +49,10 @@ const render = () => {
|
||||
describe('MediaHostingSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectedProviderRef.value = 'catbox';
|
||||
uploadModeRef.value = 'random';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
mockGetPlatform.mockReturnValue('web');
|
||||
(window as unknown as { electronApi?: unknown }).electronApi = undefined;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -49,44 +63,132 @@ describe('MediaHostingSettings', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders None option and all providers', () => {
|
||||
it('renders mode options: Random, Preferred, None', () => {
|
||||
render();
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
expect(radios.length).toBe(MEDIA_HOSTING_PROVIDERS.length + 1);
|
||||
const radios = container.querySelectorAll('input[name="media-hosting-provider"]');
|
||||
expect(radios.length).toBe(3);
|
||||
expect(container.textContent).toContain('media_hosting_random');
|
||||
expect(container.textContent).toContain('media_hosting_preferred');
|
||||
expect(container.textContent).toContain('media_hosting_none');
|
||||
});
|
||||
|
||||
it('shows provider list when Preferred is selected', () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
render();
|
||||
for (const provider of MEDIA_HOSTING_PROVIDERS) {
|
||||
expect(container.textContent).toContain(provider.name);
|
||||
expect(container.textContent).toContain(provider.url);
|
||||
expect(container.textContent).toContain(provider.label);
|
||||
expect(container.textContent).toContain(provider.homepageUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it('default selected is catbox', () => {
|
||||
it('default selected is random', () => {
|
||||
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);
|
||||
const randomRadio = container.querySelector<HTMLInputElement>('input[value="random"]');
|
||||
expect(randomRadio).not.toBeNull();
|
||||
expect(randomRadio?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a radio updates store selection', async () => {
|
||||
it('clicking None updates store via setUploadMode', async () => {
|
||||
mockGetPlatform.mockReturnValue('android');
|
||||
render();
|
||||
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
|
||||
expect(noneRadio).not.toBeNull();
|
||||
await act(async () => {
|
||||
noneRadio?.click();
|
||||
});
|
||||
expect(mockSetSelectedProvider).toHaveBeenCalledWith('none');
|
||||
expect(mockSetUploadMode).toHaveBeenCalledWith('none');
|
||||
});
|
||||
|
||||
mockSetSelectedProvider.mockClear();
|
||||
selectedProviderRef.value = 'none';
|
||||
it('clicking provider in preferred mode calls setPreferredProvider', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
mockGetPlatform.mockReturnValue('android');
|
||||
render();
|
||||
const imgurRadio = container.querySelector<HTMLInputElement>('input[value="imgur"]');
|
||||
expect(imgurRadio).not.toBeNull();
|
||||
expect(imgurRadio?.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
imgurRadio?.click();
|
||||
});
|
||||
expect(mockSetPreferredProvider).toHaveBeenCalledWith('imgur');
|
||||
});
|
||||
|
||||
it('clicking Random updates store via setUploadMode', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
mockGetPlatform.mockReturnValue('android');
|
||||
render();
|
||||
const randomRadio = container.querySelector<HTMLInputElement>('input[value="random"]');
|
||||
expect(randomRadio).not.toBeNull();
|
||||
await act(async () => {
|
||||
randomRadio?.click();
|
||||
});
|
||||
expect(mockSetUploadMode).toHaveBeenCalledWith('random');
|
||||
});
|
||||
|
||||
it('clicking Preferred updates store via setUploadMode', async () => {
|
||||
mockGetPlatform.mockReturnValue('android');
|
||||
render();
|
||||
const preferredRadio = container.querySelector<HTMLInputElement>('input[value="preferred"]');
|
||||
expect(preferredRadio).not.toBeNull();
|
||||
await act(async () => {
|
||||
preferredRadio?.click();
|
||||
});
|
||||
expect(mockSetUploadMode).toHaveBeenCalledWith('preferred');
|
||||
});
|
||||
|
||||
it('on web runtime, warning renders above mode radios and all radios are disabled', () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
mockGetPlatform.mockReturnValue('web');
|
||||
(window as unknown as { electronApi?: unknown }).electronApi = undefined;
|
||||
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);
|
||||
const warningDiv = Array.from(container.querySelectorAll('div')).find((d) => d.textContent?.includes('upload_not_supported_web_before_link'));
|
||||
expect(warningDiv).toBeTruthy();
|
||||
const firstModeRadio = container.querySelector<HTMLInputElement>('input[name="media-hosting-provider"]');
|
||||
expect(firstModeRadio).toBeTruthy();
|
||||
expect(warningDiv!.compareDocumentPosition(firstModeRadio!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
const modeRadios = container.querySelectorAll<HTMLInputElement>('input[name="media-hosting-provider"]');
|
||||
expect(modeRadios.length).toBe(3);
|
||||
for (const radio of modeRadios) {
|
||||
expect(radio.disabled).toBe(true);
|
||||
}
|
||||
|
||||
const providerRadios = container.querySelectorAll<HTMLInputElement>('input[name="media-hosting-provider-provider"]');
|
||||
expect(providerRadios.length).toBe(MEDIA_HOSTING_PROVIDERS.length);
|
||||
for (const radio of providerRadios) {
|
||||
expect(radio.disabled).toBe(true);
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain('upload_not_supported_web_before_link');
|
||||
expect(container.textContent).toContain('upload_not_supported_web_link_text');
|
||||
});
|
||||
|
||||
it('on web runtime, no Catbox-only web hint key is referenced', () => {
|
||||
mockGetPlatform.mockReturnValue('web');
|
||||
(window as unknown as { electronApi?: unknown }).electronApi = undefined;
|
||||
render();
|
||||
expect(container.textContent).not.toContain('media_hosting_provider_disabled_on_web');
|
||||
});
|
||||
|
||||
it('on electron runtime, all radios are enabled and no CORS warning is shown', () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
mockGetPlatform.mockReturnValue('web');
|
||||
(window as unknown as { electronApi?: unknown }).electronApi = { isElectron: true };
|
||||
render();
|
||||
|
||||
const modeRadios = container.querySelectorAll<HTMLInputElement>('input[name="media-hosting-provider"]');
|
||||
expect(modeRadios.length).toBe(3);
|
||||
for (const radio of modeRadios) {
|
||||
expect(radio.disabled).toBe(false);
|
||||
}
|
||||
|
||||
const providerRadios = container.querySelectorAll<HTMLInputElement>('input[name="media-hosting-provider-provider"]');
|
||||
expect(providerRadios.length).toBe(MEDIA_HOSTING_PROVIDERS.length);
|
||||
for (const radio of providerRadios) {
|
||||
expect(radio.disabled).toBe(false);
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toContain('upload_not_supported_web_before_link');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store';
|
||||
import type { ProviderId } from '../../../lib/media-hosting/types';
|
||||
import styles from '../interface-settings/interface-settings.module.css';
|
||||
|
||||
const RADIO_NAME = 'media-hosting-provider';
|
||||
const RELEASES_URL = 'https://github.com/bitsocialhq/5chan/releases/latest';
|
||||
|
||||
function isWebRuntime(): boolean {
|
||||
return Capacitor.getPlatform() === 'web' && !window.electronApi?.isElectron;
|
||||
}
|
||||
|
||||
const MediaHostingSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const setSelectedProvider = useMediaHostingStore((state) => state.setSelectedProvider);
|
||||
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||
const preferredProvider = useMediaHostingStore((state) => state.preferredProvider);
|
||||
const setUploadMode = useMediaHostingStore((state) => state.setUploadMode);
|
||||
const setPreferredProvider = useMediaHostingStore((state) => state.setPreferredProvider);
|
||||
const isWeb = isWebRuntime();
|
||||
|
||||
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>
|
||||
{isWeb && (
|
||||
<div className={styles.webUploadWarning}>
|
||||
{t('upload_not_supported_web_before_link')}{' '}
|
||||
<a href={RELEASES_URL} target='_blank' rel='noopener noreferrer'>
|
||||
{t('upload_not_supported_web_link_text')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='radio' name={RADIO_NAME} value='none' checked={selectedProvider === 'none'} onChange={() => setSelectedProvider('none')} />
|
||||
<input type='radio' name={RADIO_NAME} value='random' checked={uploadMode === 'random'} onChange={() => setUploadMode('random')} disabled={isWeb} />
|
||||
{t('media_hosting_random')}
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='radio' name={RADIO_NAME} value='preferred' checked={uploadMode === 'preferred'} onChange={() => setUploadMode('preferred')} disabled={isWeb} />
|
||||
{t('media_hosting_preferred')}
|
||||
</label>
|
||||
{uploadMode === 'preferred' && (
|
||||
<div role='radiogroup' aria-label={t('media_hosting_preferred_provider_label')}>
|
||||
{MEDIA_HOSTING_PROVIDERS.map((provider) => (
|
||||
<div key={provider.id} className={styles.setting}>
|
||||
<label>
|
||||
<input
|
||||
type='radio'
|
||||
name={`${RADIO_NAME}-provider`}
|
||||
value={provider.id}
|
||||
checked={preferredProvider === provider.id}
|
||||
onChange={() => setPreferredProvider(provider.id as ProviderId)}
|
||||
disabled={isWeb}
|
||||
/>
|
||||
{provider.label} (
|
||||
<a href={provider.homepageUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{provider.homepageUrl}
|
||||
</a>
|
||||
)
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='radio' name={RADIO_NAME} value='none' checked={uploadMode === 'none'} onChange={() => setUploadMode('none')} disabled={isWeb} />
|
||||
{t('media_hosting_none')}
|
||||
</label>
|
||||
<div className={styles.settingTip}>{t('media_hosting_none_tip')}</div>
|
||||
|
||||
Reference in New Issue
Block a user