feat: add shared cross-platform catbox media upload flow

Added `useFileUpload()` and `uploadToCatbox()` to centralize upload behavior and remove duplicated upload code from `post-form.tsx` and `reply-modal.tsx`. The flow now routes Android to `FileUploader`, Electron to hidden-input + catbox upload, and web to a translated fallback alert with consistent upload UI state.
This commit is contained in:
plebeius
2026-02-17 16:44:16 +08:00
parent 9c558d21c1
commit dbbc83d369
42 changed files with 545 additions and 118 deletions
+18 -41
View File
@@ -15,13 +15,10 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import FileUploader from '../../plugins/file-uploader';
import { useFileUpload } from '../../hooks/use-file-upload';
import styles from './post-form.module.css';
import { Capacitor } from '@capacitor/core';
import { capitalize, debounce } from 'lodash';
const isAndroid = Capacitor.getPlatform() === 'android';
// Separate component for offline alert to isolate rerenders from updatingState
// Only this component will rerender when updatingState changes, not the whole PostForm
const OfflineAlert = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => {
@@ -197,35 +194,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
}, [replyIndex, resetPublishReplyOptions, closeForm]);
// on android, auto upload file to image hosting sites with open api
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const handleUpload = async () => {
try {
setIsUploading(true);
const result = await FileUploader.pickAndUploadMedia();
console.log('Upload result:', result);
if (result.url) {
setUrl(result.url);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
if (urlRef.current) {
urlRef.current.value = result.url;
}
isInPostView ? setPublishReplyOptions({ link: result.url || undefined }) : setPublishPostOptions({ link: result.url || undefined });
if (result.fileName) {
setUploadedFileName(result.fileName);
urlRef.current.value = uploadedUrl;
}
isInPostView ? setPublishReplyOptions({ link: uploadedUrl }) : setPublishPostOptions({ link: uploadedUrl });
}
} catch (error) {
console.error('Upload failed:', error);
if (error instanceof Error && error.message !== 'File selection cancelled') {
alert(`${t('upload_failed')}: ${error.message}`);
} else if (typeof error === 'string' && error !== 'File selection cancelled') {
alert(`${t('upload_failed')}: ${error}`);
}
} finally {
setIsUploading(false);
}
};
},
});
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -306,17 +285,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
</td>
</tr>
{isAndroid && (
<tr className={styles.uploadButton}>
<td>{t('file')}</td>
<td>
<button onClick={handleUpload} disabled={isUploading}>
{isUploading ? t('uploading') : t('choose_file')}
</button>
<span>{uploadedFileName ? uploadedFileName : t('no_file_chosen')}</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>
<tr className={styles.spoilerButton}>
<td>{t('options')}</td>
<td>
+20 -42
View File
@@ -11,16 +11,13 @@ import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload';
import styles from './reply-modal.module.css';
import { LinkTypePreviewer } from '../post-form';
import { capitalize, debounce } from 'lodash';
import FileUploader from '../../plugins/file-uploader';
import { Capacitor } from '@capacitor/core';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
const isAndroid = Capacitor.getPlatform() === 'android';
interface ReplyModalProps {
closeModal: () => void;
showReplyModal: boolean;
@@ -263,35 +260,17 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
checkContentLength(formattedContent, t);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, checkContentLength, t]);
// on android, auto upload file to image hosting sites with open api
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const handleUpload = async () => {
try {
setIsUploading(true);
const result = await FileUploader.pickAndUploadMedia();
console.log('Upload result:', result);
if (result.url) {
setUrl(result.url);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
if (urlRef.current) {
urlRef.current.value = result.url;
}
setPublishReplyOptions({ link: result.url || undefined });
if (result.fileName) {
setUploadedFileName(result.fileName);
urlRef.current.value = uploadedUrl;
}
setPublishReplyOptions({ link: uploadedUrl });
}
} catch (error) {
console.error('Upload failed, ', error);
if (error instanceof Error && error.message !== 'File selection cancelled') {
setError(`${t('upload_failed')}, ${error.message}`);
} else if (typeof error === 'string' && error !== 'File selection cancelled') {
setError(`${t('upload_failed')}, ${error}`);
}
} finally {
setIsUploading(false);
}
};
},
});
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -339,6 +318,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
type='text'
ref={urlRef}
placeholder={capitalize(t('link'))}
disabled={isUploading}
onChange={(e) => {
setUrl(e.target.value);
setPublishReplyOptions({ link: e.target.value });
@@ -365,23 +345,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
/>
</div>
<div className={styles.footer}>
{url && !isAndroid && (
{url && (
<>
{t('link_type')}: <LinkTypePreviewer link={url} />
</>
)}
{isAndroid && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
{isUploading ? t('uploading') : t('choose_file')}
</button>
</span>
<span className={styles.uploadFileName} title={uploadedFileName ? uploadedFileName : t('no_file_chosen')}>
{uploadedFileName ? uploadedFileName : t('no_file_chosen')}
</span>
<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.spoilerButton}>
[
<label>