fix(post form): publish twimg query-format links with path extension

Normalize pbs.twimg.com media URLs that use ?format= to their file-extension form at publish time without rewriting the input field.
This commit is contained in:
Tommaso Casaburi
2026-06-04 18:00:25 +07:00
parent 8badc2b7a5
commit 3ece699a6f
4 changed files with 146 additions and 30 deletions
@@ -313,37 +313,57 @@ vi.mock('../../loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
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 };
}
if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) {
return { type: 'image', url: link };
}
if (link.endsWith('.mp4')) {
return { type: 'video', url: link };
}
if (link.endsWith('.mp3')) {
return { type: 'audio', url: link };
}
if (link.includes('youtube.com')) {
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', type: 'iframe', url: link };
}
return { type: 'webpage', url: link };
},
getYouTubeThumbnailUrlFromLink: (link: string) => {
vi.mock('../../../lib/utils/media-utils', () => {
const getTwimgMediaFilePublishUrl = (link: string) => {
try {
const url = new URL(link);
if (!url.hostname.includes('youtube.com')) return undefined;
const videoId = url.searchParams.get('v');
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
const url = new URL(link.trim());
const pathParts = url.pathname.split('/').filter(Boolean);
const [mediaPath, mediaId] = pathParts;
const format = url.searchParams.get('format')?.toLowerCase();
if (url.hostname !== 'pbs.twimg.com' || pathParts.length !== 2 || mediaPath !== 'media' || !mediaId || mediaId.includes('.')) return undefined;
if (!format || !['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(format)) return undefined;
return `https://pbs.twimg.com/media/${mediaId}.${format}`;
} catch {
return undefined;
}
},
}));
};
return {
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
getLinkMediaInfo: (link: string) => {
if (getTwimgMediaFilePublishUrl(link)) {
return { type: 'image', url: link };
}
if (link.endsWith('.gif')) {
return { type: 'gif', url: link };
}
if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) {
return { type: 'image', url: link };
}
if (link.endsWith('.mp4')) {
return { type: 'video', url: link };
}
if (link.endsWith('.mp3')) {
return { type: 'audio', url: link };
}
if (link.includes('youtube.com')) {
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', type: 'iframe', url: link };
}
return { type: 'webpage', url: link };
},
getTwimgMediaFilePublishUrl,
getYouTubeThumbnailUrlFromLink: (link: string) => {
try {
const url = new URL(link);
if (!url.hostname.includes('youtube.com')) return undefined;
const videoId = url.searchParams.get('v');
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
} catch {
return undefined;
}
},
};
});
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
@@ -718,6 +738,32 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
});
it('publishes known twimg query-format post links with a path extension without editing the field', async () => {
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const select = table.querySelector('select') as HTMLSelectElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
await dispatchChange(select, 'music-posting.eth');
await dispatchInput(textarea, 'Twimg thread');
await dispatchInput(linkInput, twimgLink);
await clickByText(table, 'post');
expect(linkInput.value).toBe(twimgLink);
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'Twimg thread',
link: publishLink,
});
expect(testState.publishedPostOptions?.link).toBe(publishLink);
});
it('shows Oekaki draw controls only on the /i/ board form', async () => {
testState.directories.push({
address: 'oekaki-posting.bso',
@@ -1430,6 +1476,36 @@ describe('PostForm', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('publishes known twimg query-format reply links with a path extension without editing the field', async () => {
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
},
};
testState.resolvedCommunityAddress = 'music-posting.eth';
await renderPostForm('/mu/thread/thread-cid');
await clickByText(container, 'post_a_reply');
const table = container.querySelector('table') as HTMLTableElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(textarea, 'Twimg reply');
await dispatchInput(linkInput, twimgLink);
await clickByText(table, 'post');
expect(linkInput.value).toBe(twimgLink);
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'Twimg reply',
link: publishLink,
});
});
});
describe('LinkTypePreviewer', () => {
+12 -3
View File
@@ -5,7 +5,7 @@ import { Link, 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 { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
import {
type DiceRoll,
@@ -72,6 +72,15 @@ const isPostFormFileMediaType = (type: string | undefined): boolean => Boolean(t
const isPostFormFileMediaLink = (link: string): boolean => isPostFormFileMediaType(getLinkMediaInfo(link)?.type);
const getPublishLinkOptions = (link: string, includeCurrentLink: boolean): Partial<Pick<Comment, 'link'>> => {
const twimgPublishUrl = getTwimgMediaFilePublishUrl(link);
if (twimgPublishUrl) {
return { link: twimgPublishUrl };
}
return includeCurrentLink && link ? { link } : {};
};
export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string; requireFile?: boolean }) => {
const { t } = useTranslation();
const mediaInfo = getLinkMediaInfo(link);
@@ -673,7 +682,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...publishOptions });
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
};
// redirect to pending page when pending comment is created
@@ -790,7 +799,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
};
const setLinkValue = (nextUrl: string) => {
@@ -45,6 +45,7 @@ import {
getLinkMediaInfo,
getMediaDimensions,
getPostMediaTypeLabel,
getTwimgMediaFilePublishUrl,
getYouTubeEmbedPostMediaFileLink,
getYouTubeThumbnailUrlFromLink,
} from '../media-utils';
@@ -192,6 +193,14 @@ describe('media-utils', () => {
});
});
it('normalizes known twimg query-format media links for publishing', () => {
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium')).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg');
expect(getTwimgMediaFilePublishUrl('http://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=PNG&name=small')).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.png');
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg?format=png&name=medium')).toBeUndefined();
expect(getTwimgMediaFilePublishUrl('https://example.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium')).toBeUndefined();
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=txt&name=medium')).toBeUndefined();
});
it('builds comment media info and strips thumbnails for blacklisted domains', () => {
testState.canEmbedHosts = new Set(['www.youtube.com']);
+22
View File
@@ -112,6 +112,7 @@ const KNOWN_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv'
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
const KNOWN_SWF_EXTENSIONS = ['swf'];
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
const TWIMG_MEDIA_FORMAT_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']);
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
@@ -157,6 +158,27 @@ const getDirectMediaExtension = (url: URL): string => {
return pathParts.length > 1 ? pathParts[pathParts.length - 1] : '';
};
export const getTwimgMediaFilePublishUrl = (link: string): string | undefined => {
const url = parseHttpUrl(link.trim());
if (!url || url.hostname.toLowerCase() !== 'pbs.twimg.com') {
return undefined;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [mediaPath, mediaId] = pathParts;
if (pathParts.length !== 2 || mediaPath !== 'media' || !mediaId || mediaId.includes('.')) {
return undefined;
}
const format = url.searchParams.get('format')?.toLowerCase();
if (!format || !TWIMG_MEDIA_FORMAT_EXTENSIONS.has(format)) {
return undefined;
}
url.protocol = 'https:';
return `${url.origin}/media/${mediaId}.${format}`;
};
export const getLinkMediaInfo = memoize(
(link: string): CommentMediaInfo | undefined => {
if (!isValidURL(link)) {