diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index df75db77..313f52fa 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -309,7 +309,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({ }, })); -vi.mock('../../loading-ellipsis', () => ({ +vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), })); @@ -331,19 +331,26 @@ vi.mock('../../../lib/utils/media-utils', () => { return { getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type), getLinkMediaInfo: (link: string) => { + const path = (() => { + try { + return new URL(link).pathname; + } catch { + return link; + } + })(); if (getTwimgMediaFilePublishUrl(link)) { return { type: 'image', url: link }; } - if (link.endsWith('.gif')) { + if (path.endsWith('.gif')) { return { type: 'gif', url: link }; } - if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) { + if (path.endsWith('.jpg') || path.endsWith('.jpeg') || path.endsWith('.png')) { return { type: 'image', url: link }; } - if (link.endsWith('.mp4')) { + if (path.endsWith('.mp4')) { return { type: 'video', url: link }; } - if (link.endsWith('.mp3')) { + if (path.endsWith('.mp3')) { return { type: 'audio', url: link }; } if (link.includes('youtube.com')) { @@ -1306,6 +1313,23 @@ describe('PostForm', () => { expect(table?.textContent).toContain('file name.jpg'); }); + it('does not show a pasted non-file path segment 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[3]; + + await dispatchInput(linkInput as HTMLInputElement, 'https://imgur.com/8EJ2T76'); + + expect(table?.textContent).toContain('not_a_file'); + expect(table?.textContent).toContain('no_file_chosen'); + expect(table?.textContent).not.toContain('8EJ2T76'); + }); + it('shows the rules and FAQ prompt at the bottom of the form with a board-specific rules link', async () => { testState.resolvedCommunityAddress = 'music-posting.eth'; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index efc6d2fa..0a49487e 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -6,7 +6,7 @@ import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bi import getShortAddress from '../../lib/get-short-address'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils'; -import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; +import { getExpiringMediaLinkAlert, getPublishFileDisplayName, isPublishFileMediaLink, isPublishFileMediaType } from '../../lib/utils/media-link-validation-utils'; import { type DiceRoll, type FortuneEntry, @@ -21,7 +21,7 @@ import { isPostOptionsValidationError, } from '../../lib/utils/post-options-utils'; import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils'; -import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; +import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { getBoardPath } from '../../lib/utils/route-utils'; @@ -46,8 +46,8 @@ import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; -import LoadingEllipsis from '../loading-ellipsis'; -import OekakiDrawingControls from '../oekaki-drawing-controls'; +import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis'; +import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls'; import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message'; import styles from './post-form.module.css'; import capitalize from 'lodash/capitalize'; @@ -55,23 +55,18 @@ import debounce from 'lodash/debounce'; const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg'; const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28; -const POST_FORM_FILE_MEDIA_TYPES = new Set(['gif', 'image', 'video']); const mergeFlairs = (...flairGroups: Array): Comment['flairs'] | undefined => { const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : [])); return flairs.length > 0 ? flairs : undefined; }; -const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => { - const raw = getPublishURLFilename(url) || uploadedFileName; +const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string, requireFile: boolean): string => { + const raw = getPublishFileDisplayName(url, uploadedFileName, requireFile); if (!raw) return noFileLabel; return truncateWithEllipsisInMiddle(raw, POST_FORM_FILE_DISPLAY_MAX_LENGTH); }; -const isPostFormFileMediaType = (type: string | undefined): boolean => Boolean(type && POST_FORM_FILE_MEDIA_TYPES.has(type)); - -const isPostFormFileMediaLink = (link: string): boolean => isPostFormFileMediaType(getLinkMediaInfo(link)?.type); - const getPublishLinkOptions = (link: string, includeCurrentLink: boolean): Partial> => { const twimgPublishUrl = getTwimgMediaFilePublishUrl(link); if (twimgPublishUrl) { @@ -87,7 +82,7 @@ export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string; let type = mediaInfo?.type; const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined); - if (requireFile && isValidURL(link) && !isPostFormFileMediaType(type)) { + if (requireFile && isValidURL(link) && !isPublishFileMediaType(type)) { return {t('not_a_file')}; } @@ -400,8 +395,8 @@ const PostFormFields = ({ isUploading={isUploading} showUploadControls={showUploadControls} /> - - {isUploading ? : getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'))} + + {isUploading ? : getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'), requirePostLinkIsMedia)} @@ -653,7 +648,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: setFormError(`${t('error')}: ${t('invalid_url_alert')}`); return; } - if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) { + if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) { setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`); return; } @@ -781,7 +776,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: setFormError(`${t('error')}: ${t('invalid_url_alert')}`); return; } - if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) { + if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) { setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`); return; } diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 35e9dc8d..2b9f0ad7 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -223,7 +223,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({ }, })); -vi.mock('../../loading-ellipsis', () => ({ +vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), })); @@ -1021,6 +1021,21 @@ describe('ReplyModal', () => { expect(container.textContent).not.toContain('Spoiler?'); }); + it('only shows pasted link filenames after media-only links are confirmed as files', async () => { + await renderReplyModal('/all/thread/post-1'); + + const linkInput = container.querySelectorAll('input[type="text"]')[2]; + + await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76'); + + expect(container.textContent).toContain('no_file_chosen'); + expect(container.textContent).not.toContain('8EJ2T76'); + + await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76.jpg'); + + expect(container.textContent).toContain('8EJ2T76.jpg'); + }); + it('positions the draggable modal with left/top styles instead of a transform layer', async () => { await renderReplyModal('/mu/thread/post-1'); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 2cd6f191..25c60832 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; -import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils'; +import { getExpiringMediaLinkAlert, getPublishFileDisplayName } from '../../lib/utils/media-link-validation-utils'; import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection'; import { type DiceRoll, @@ -17,7 +17,7 @@ import { hasNonokoOption, isPostOptionsValidationError, } from '../../lib/utils/post-options-utils'; -import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils'; +import { isValidPublishURL } from '../../lib/utils/url-utils'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; @@ -35,8 +35,8 @@ import { useCommunityField } from '../../hooks/use-stable-community'; import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; -import LoadingEllipsis from '../loading-ellipsis'; -import OekakiDrawingControls from '../oekaki-drawing-controls'; +import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis'; +import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls'; import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message'; import styles from './reply-modal.module.css'; import capitalize from 'lodash/capitalize'; @@ -498,7 +498,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 displayedFileName = getPublishFileDisplayName(url, uploadedFileName, requirePostLinkIsMedia); const youtubeThumbnailConversionNotice = youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null; diff --git a/src/lib/utils/media-link-validation-utils.ts b/src/lib/utils/media-link-validation-utils.ts index b0c94f58..8db2b41c 100644 --- a/src/lib/utils/media-link-validation-utils.ts +++ b/src/lib/utils/media-link-validation-utils.ts @@ -1,8 +1,25 @@ -import { getExpiringMediaLinkHostname } from './url-utils'; +import { getExpiringMediaLinkHostname, getPublishURLFilename } from './url-utils'; +import { getLinkMediaInfo } from './media-utils'; type TranslateFn = (key: string, options?: Record) => string; +const PUBLISH_FILE_MEDIA_TYPES = new Set(['gif', 'image', 'video']); + export const getExpiringMediaLinkAlert = (url: string, t: TranslateFn): string | null => { const expiringMediaLinkHostname = getExpiringMediaLinkHostname(url); return expiringMediaLinkHostname ? `${t('error')}: ${t('expiring_media_link_alert', { domain: expiringMediaLinkHostname })}` : null; }; + +export const isPublishFileMediaType = (type: string | undefined): boolean => Boolean(type && PUBLISH_FILE_MEDIA_TYPES.has(type)); + +export const isPublishFileMediaLink = (link: string): boolean => isPublishFileMediaType(getLinkMediaInfo(link)?.type); + +export const getPublishFileDisplayName = (url: string, uploadedFileName: string | null | undefined, requireFile: boolean): string | null => { + if (!url) { + return uploadedFileName || null; + } + if (requireFile && !isPublishFileMediaLink(url)) { + return null; + } + return getPublishURLFilename(url) || uploadedFileName || null; +};