Merge branch 'codex/fix/link-file-filename-validation'

This commit is contained in:
Tommaso Casaburi
2026-06-09 12:34:18 +07:00
5 changed files with 79 additions and 28 deletions
@@ -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), default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
})); }));
@@ -331,19 +331,26 @@ vi.mock('../../../lib/utils/media-utils', () => {
return { return {
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type), getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
getLinkMediaInfo: (link: string) => { getLinkMediaInfo: (link: string) => {
const path = (() => {
try {
return new URL(link).pathname;
} catch {
return link;
}
})();
if (getTwimgMediaFilePublishUrl(link)) { if (getTwimgMediaFilePublishUrl(link)) {
return { type: 'image', url: link }; return { type: 'image', url: link };
} }
if (link.endsWith('.gif')) { if (path.endsWith('.gif')) {
return { type: 'gif', url: link }; 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 }; return { type: 'image', url: link };
} }
if (link.endsWith('.mp4')) { if (path.endsWith('.mp4')) {
return { type: 'video', url: link }; return { type: 'video', url: link };
} }
if (link.endsWith('.mp3')) { if (path.endsWith('.mp3')) {
return { type: 'audio', url: link }; return { type: 'audio', url: link };
} }
if (link.includes('youtube.com')) { if (link.includes('youtube.com')) {
@@ -1306,6 +1313,23 @@ describe('PostForm', () => {
expect(table?.textContent).toContain('file name.jpg'); 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<HTMLInputElement>('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 () => { 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'; testState.resolvedCommunityAddress = 'music-posting.eth';
+11 -16
View File
@@ -6,7 +6,7 @@ import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bi
import getShortAddress from '../../lib/get-short-address'; import getShortAddress from '../../lib/get-short-address';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils'; 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 { import {
type DiceRoll, type DiceRoll,
type FortuneEntry, type FortuneEntry,
@@ -21,7 +21,7 @@ import {
isPostOptionsValidationError, isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils'; } from '../../lib/utils/post-options-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-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 { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath } from '../../lib/utils/route-utils'; 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 useMediaHostingStore from '../../stores/use-media-hosting-store';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import LoadingEllipsis from '../loading-ellipsis'; import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls'; import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message'; import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './post-form.module.css'; import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
@@ -55,23 +55,18 @@ import debounce from 'lodash/debounce';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg'; const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28; 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>): Comment['flairs'] | undefined => { const mergeFlairs = (...flairGroups: Array<Comment['flairs'] | undefined>): Comment['flairs'] | undefined => {
const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : [])); const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : []));
return flairs.length > 0 ? flairs : undefined; return flairs.length > 0 ? flairs : undefined;
}; };
const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => { const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string, requireFile: boolean): string => {
const raw = getPublishURLFilename(url) || uploadedFileName; const raw = getPublishFileDisplayName(url, uploadedFileName, requireFile);
if (!raw) return noFileLabel; if (!raw) return noFileLabel;
return truncateWithEllipsisInMiddle(raw, POST_FORM_FILE_DISPLAY_MAX_LENGTH); 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<Pick<Comment, 'link'>> => { const getPublishLinkOptions = (link: string, includeCurrentLink: boolean): Partial<Pick<Comment, 'link'>> => {
const twimgPublishUrl = getTwimgMediaFilePublishUrl(link); const twimgPublishUrl = getTwimgMediaFilePublishUrl(link);
if (twimgPublishUrl) { if (twimgPublishUrl) {
@@ -87,7 +82,7 @@ export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string;
let type = mediaInfo?.type; let type = mediaInfo?.type;
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined); const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined);
if (requireFile && isValidURL(link) && !isPostFormFileMediaType(type)) { if (requireFile && isValidURL(link) && !isPublishFileMediaType(type)) {
return <span className={styles.linkTypeError}>{t('not_a_file')}</span>; return <span className={styles.linkTypeError}>{t('not_a_file')}</span>;
} }
@@ -400,8 +395,8 @@ const PostFormFields = ({
isUploading={isUploading} isUploading={isUploading}
showUploadControls={showUploadControls} showUploadControls={showUploadControls}
/> />
<span title={getPublishURLFilename(url) || uploadedFileName || undefined}> <span title={getPublishFileDisplayName(url, uploadedFileName, requirePostLinkIsMedia) || undefined}>
{isUploading ? <LoadingEllipsis string={t('uploading')} /> : getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'))} {isUploading ? <LoadingEllipsis string={t('uploading')} /> : getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'), requirePostLinkIsMedia)}
</span> </span>
</td> </td>
</tr> </tr>
@@ -653,7 +648,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(`${t('error')}: ${t('invalid_url_alert')}`); setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return; return;
} }
if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) { if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`); setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return; return;
} }
@@ -781,7 +776,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(`${t('error')}: ${t('invalid_url_alert')}`); setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return; return;
} }
if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) { if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`); setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return; return;
} }
@@ -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), default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
})); }));
@@ -1021,6 +1021,21 @@ describe('ReplyModal', () => {
expect(container.textContent).not.toContain('Spoiler?'); 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<HTMLInputElement>('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 () => { it('positions the draggable modal with left/top styles instead of a transform layer', async () => {
await renderReplyModal('/mu/thread/post-1'); await renderReplyModal('/mu/thread/post-1');
+5 -5
View File
@@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; 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 { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
import { import {
type DiceRoll, type DiceRoll,
@@ -17,7 +17,7 @@ import {
hasNonokoOption, hasNonokoOption,
isPostOptionsValidationError, isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils'; } 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 { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils'; import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-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 { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar'; import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import LoadingEllipsis from '../loading-ellipsis'; import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls'; import OekakiDrawingControls from '../oekaki-drawing-controls/oekaki-drawing-controls';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message'; import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './reply-modal.module.css'; import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
@@ -498,7 +498,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}; };
const uploadMode = useMediaHostingStore((state) => state.uploadMode); const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishURLFilename(url) || uploadedFileName; const displayedFileName = getPublishFileDisplayName(url, uploadedFileName, requirePostLinkIsMedia);
const youtubeThumbnailConversionNotice = const youtubeThumbnailConversionNotice =
youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null; youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null;
+18 -1
View File
@@ -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, unknown>) => string; type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
const PUBLISH_FILE_MEDIA_TYPES = new Set(['gif', 'image', 'video']);
export const getExpiringMediaLinkAlert = (url: string, t: TranslateFn): string | null => { export const getExpiringMediaLinkAlert = (url: string, t: TranslateFn): string | null => {
const expiringMediaLinkHostname = getExpiringMediaLinkHostname(url); const expiringMediaLinkHostname = getExpiringMediaLinkHostname(url);
return expiringMediaLinkHostname ? `${t('error')}: ${t('expiring_media_link_alert', { domain: expiringMediaLinkHostname })}` : null; 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;
};