diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 53bf8d78..20586860 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -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('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' })); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 4ae3be5f..388624d7 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -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} /> - {isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')} + {isUploading ? t('uploading') : getPublishURLFilename(url) || uploadedFileName || t('no_file_chosen')} )} diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 049e96c2..294a5e07 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -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('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?'); }); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 55f38b19..23003c1f 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -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(null); const [lengthError, setLengthError] = useState(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 }); + }} />
@@ -365,8 +373,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa {t('choose_file')} - - {isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')} + + {isUploading ? t('uploading') : displayedFileName || t('no_file_chosen')} )} diff --git a/src/lib/utils/__tests__/url-utils.test.ts b/src/lib/utils/__tests__/url-utils.test.ts index 74be732f..7c62e6da 100644 --- a/src/lib/utils/__tests__/url-utils.test.ts +++ b/src/lib/utils/__tests__/url-utils.test.ts @@ -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 () => { diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index 5c891f7f..6b514484 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -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 {