From 3ece699a6ff583cbd51c138f16b241f318b9e2b1 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 4 Jun 2026 18:00:25 +0700 Subject: [PATCH] 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. --- .../post-form/__tests__/post-form.test.tsx | 130 ++++++++++++++---- src/components/post-form/post-form.tsx | 15 +- src/lib/utils/__tests__/media-utils.test.ts | 9 ++ src/lib/utils/media-utils.ts | 22 +++ 4 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index d8587961..b49ee26e 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -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('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('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', () => { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index e63d6edd..684bc855 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -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> => { + 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) => { diff --git a/src/lib/utils/__tests__/media-utils.test.ts b/src/lib/utils/__tests__/media-utils.test.ts index 327e1988..c2ee72bb 100644 --- a/src/lib/utils/__tests__/media-utils.test.ts +++ b/src/lib/utils/__tests__/media-utils.test.ts @@ -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']); diff --git a/src/lib/utils/media-utils.ts b/src/lib/utils/media-utils.ts index 47173daa..cd96b27c 100644 --- a/src/lib/utils/media-utils.ts +++ b/src/lib/utils/media-utils.ts @@ -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)) {