fix(posting): clarify direct file links

This commit is contained in:
Tommaso Casaburi
2026-05-01 18:03:04 +07:00
parent 9d2f9445a3
commit bc213ecea2
6 changed files with 77 additions and 12 deletions
@@ -179,6 +179,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
}));
vi.mock('../../../lib/utils/media-utils', () => ({
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
getLinkMediaInfo: (link: string) => {
if (link.endsWith('.gif')) {
return { type: 'gif', url: link };
@@ -186,6 +187,9 @@ vi.mock('../../../lib/utils/media-utils', () => ({
if (link.endsWith('.png')) {
return { type: 'image', url: link };
}
if (link.endsWith('.mp4')) {
return { type: 'video', url: link };
}
return { type: 'link', url: link };
},
}));
@@ -252,7 +256,9 @@ const clickByText = async (scope: ParentNode, text: string, index = 0) => {
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
await act(async () => {
element.value = value;
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
descriptor?.set?.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
});
@@ -379,6 +385,7 @@ describe('PostForm', () => {
expect(nameInput).toBeTruthy();
expect(subjectInput).toBeTruthy();
expect(linkInput).toBeTruthy();
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
expect(textarea).toBeTruthy();
expect(select).toBeTruthy();
@@ -409,6 +416,23 @@ describe('PostForm', () => {
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
});
it('shows the pasted file-link filename next to the upload button', async () => {
testState.uploadedFileName = null;
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
const linkInput = textInputs[2];
expect(table?.textContent).toContain('no_file_chosen');
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/images/file%20name.jpg?size=large');
expect(table?.textContent).toContain('file name.jpg');
});
it('redirects to the pending route when a post publish index is already available on mount', async () => {
testState.postIndex = 7;
testState.resolvedCommunityAddress = 'music-posting.eth';
@@ -480,7 +504,12 @@ describe('LinkTypePreviewer', () => {
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.gif' }));
});
expect(container.textContent).toBe('animated_gif');
expect(container.textContent).toBe('type: animated_gif');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.mp4' }));
});
expect(container.textContent).toBe('type: video');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'not-a-url' }));
+9 -4
View File
@@ -5,8 +5,8 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
@@ -25,6 +25,8 @@ import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
export const LinkTypePreviewer = ({ link }: { link: string }) => {
const { t } = useTranslation();
const mediaInfo = getLinkMediaInfo(link);
@@ -35,9 +37,11 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
type = t('animated_gif');
} else if (type === 'gif') {
type = t('gif');
} else if (type) {
type = getDisplayMediaInfoType(type, t);
}
return isValidURL(link) ? type : t('invalid_url');
return isValidURL(link) ? `type: ${type}` : t('invalid_url');
};
const PostFormActions = ({
@@ -224,6 +228,7 @@ const PostFormFields = ({
autoCorrect='off'
autoComplete='off'
spellCheck='false'
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
ref={urlRef}
disabled={isUploading}
onChange={(e) => {
@@ -253,7 +258,7 @@ const PostFormFields = ({
isUploading={isUploading}
showUploadControls={showUploadControls}
/>
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
<span>{isUploading ? t('uploading') : getPublishURLFilename(url) || uploadedFileName || t('no_file_chosen')}</span>
</td>
</tr>
)}
@@ -398,6 +398,7 @@ describe('ReplyModal', () => {
await dispatchInput(linkInput, 'https://example.com/file.png');
await clickButtonByText('post');
expect(container.textContent).toContain('file.png');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/file.png' });
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
@@ -419,6 +420,7 @@ describe('ReplyModal', () => {
});
expect(linkInput?.value).toBe('https://cdn.example/uploaded.png');
expect(container.textContent).toContain('uploaded.png');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://cdn.example/uploaded.png' });
testState.replyIndex = 3;
@@ -463,7 +465,7 @@ describe('ReplyModal', () => {
await renderReplyModal('/all/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
expect(linkInput?.getAttribute('placeholder')).toContain('Link_to_file');
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
expect(container.textContent).not.toContain('warning');
expect(container.textContent).not.toContain('Spoiler?');
});
+13 -5
View File
@@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { isValidPublishURL } from '../../lib/utils/url-utils';
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store';
@@ -20,6 +20,8 @@ import debounce from 'lodash/debounce';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
interface ReplyModalProps {
closeModal: () => void;
showReplyModal: boolean;
@@ -63,6 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [error, setError] = useState<string | null>(null);
const [lengthError, setLengthError] = useState<string | null>(null);
const [url, setUrl] = useState('');
const checkContentLengthRef = useRef(
debounce((content: string, t: TFunction) => {
@@ -270,6 +273,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
if (urlRef.current) {
urlRef.current.value = uploadedUrl;
}
@@ -279,6 +283,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
});
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -332,9 +337,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
type='text'
ref={urlRef}
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={capitalize(requirePostLinkIsMedia ? t('link_to_file') : t('link'))}
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading}
onChange={(e) => setPublishReplyOptions({ link: e.target.value })}
onChange={(e) => {
setUrl(e.target.value);
setPublishReplyOptions({ link: e.target.value });
}}
/>
</div>
<div className={styles.content}>
@@ -365,8 +373,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
{t('choose_file')}
</button>
</span>
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
<span className={styles.uploadFileName} title={displayedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : displayedFileName || t('no_file_chosen')}
</span>
</span>
)}
@@ -11,6 +11,7 @@ vi.mock('../clipboard-utils', () => ({
import {
copyShareLinkToClipboard,
getHostname,
getPublishURLFilename,
is5chanLink,
isPrivateNetworkHostname,
isValidCrossboardPattern,
@@ -57,6 +58,8 @@ describe('url-utils', () => {
expect(isValidPublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe(true);
expect(isValidPublishURL('ftp://example.com/file.jpg')).toBe(false);
expect(isValidPublishURL('not-a-url')).toBe(false);
expect(getPublishURLFilename('https://example.com/images/file%20name.jpg?size=large')).toBe('file name.jpg');
expect(getPublishURLFilename('not-a-url')).toBeNull();
});
it('copies share links for threads and catalog pages using the production fallback base url', async () => {
+18
View File
@@ -75,6 +75,24 @@ export const isValidPublishURL = (url: string) => {
}
};
export const getPublishURLFilename = (url: string): string | null => {
if (!isValidPublishURL(url)) {
return null;
}
const parsedUrl = new URL(normalizePublishURL(url));
const filename = parsedUrl.pathname.split('/').filter(Boolean).pop();
if (!filename) {
return null;
}
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
};
const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app'];
function getShareBaseUrl(): string {