fix(post-form): convert twimg query-format links in the reply modal (#1168)

Share getPublishLinkOptions between the inline post form and the reply modal so the modal publishes twimg ?format= links in their .jpg/.png form (previously raw), and rewrite the link field on blur in both so the conversion is visible. Also gate the dev service worker's runtime asset cache to production so dev no longer serves stale /src modules.
This commit is contained in:
Tommaso Casaburi
2026-06-09 19:24:28 +07:00
committed by GitHub
parent ee5aa7845e
commit 2f938d59ae
6 changed files with 147 additions and 28 deletions
@@ -771,6 +771,39 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.link).toBe(publishLink);
});
it('rewrites known twimg query-format links to their path-extension form when the link field loses focus', async () => {
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const select = table.querySelector('select') as HTMLSelectElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
await dispatchChange(select, 'music-posting.eth');
const blurLinkInput = async () => {
await act(async () => {
linkInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
};
// A twimg `?format=jpg` link becomes its `.jpg` form once the field loses focus.
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium');
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium');
await blurLinkInput();
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg');
// The detected format is preserved (png stays png).
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/ZZZ9?format=png&name=orig');
await blurLinkInput();
expect(linkInput.value).toBe('https://pbs.twimg.com/media/ZZZ9.png');
// Non-twimg links are left untouched.
await dispatchInput(linkInput, 'https://example.com/photo?format=jpg&name=large');
await blurLinkInput();
expect(linkInput.value).toBe('https://example.com/photo?format=jpg&name=large');
});
it('shows Oekaki draw controls only on the /i/ board form', async () => {
testState.directories.push({
address: 'oekaki-posting.bso',
+25 -10
View File
@@ -6,7 +6,13 @@ 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, getPublishFileDisplayName, isPublishFileMediaLink, isPublishFileMediaType } from '../../lib/utils/media-link-validation-utils';
import {
getExpiringMediaLinkAlert,
getPublishFileDisplayName,
getPublishLinkOptions,
isPublishFileMediaLink,
isPublishFileMediaType,
} from '../../lib/utils/media-link-validation-utils';
import {
type DiceRoll,
type FortuneEntry,
@@ -67,15 +73,6 @@ const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | nul
return truncateWithEllipsisInMiddle(raw, POST_FORM_FILE_DISPLAY_MAX_LENGTH);
};
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);
@@ -161,6 +158,7 @@ interface PostFormFieldsProps {
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleContentValueChange: (content: string, options?: string) => void;
handleLinkChange: (link: string) => void;
handleLinkBlur: () => void;
handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disableLinkInput: boolean;
setPublishPostOptions: (opts: Record<string, unknown>) => void;
@@ -213,6 +211,7 @@ const PostFormFields = ({
handleContentChange,
handleContentValueChange,
handleLinkChange,
handleLinkBlur,
handleOptionsChange,
disableLinkInput,
setPublishPostOptions,
@@ -376,6 +375,7 @@ const PostFormFields = ({
onChange={(e) => {
handleLinkChange(e.target.value);
}}
onBlur={handleLinkBlur}
/>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} requireFile={requirePostLinkIsMedia} />}</span>
</td>
@@ -826,6 +826,20 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
};
// Normalize pbs.twimg.com `?format=` media links to their `.jpg`/`.png` form once the field
// loses focus, so the conversion that happens at publish time is visible in the input. Done on
// blur (not per keystroke) to avoid rewriting the URL while it is still being typed or pasted.
const handleLinkBlur = () => {
const currentValue = urlRef.current?.value ?? '';
const twimgPublishUrl = getTwimgMediaFilePublishUrl(currentValue);
if (twimgPublishUrl && twimgPublishUrl !== currentValue) {
if (urlRef.current) {
urlRef.current.value = twimgPublishUrl;
}
setLinkValue(twimgPublishUrl);
}
};
useEffect(() => {
if (typeof replyIndex === 'number') {
const nonokoRedirectPath = nonokoRedirectPathRef.current;
@@ -895,6 +909,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
handleContentChange={handleContentChange}
handleContentValueChange={handleContentValueChange}
handleLinkChange={handleLinkChange}
handleLinkBlur={handleLinkBlur}
handleOptionsChange={handleOptionsChange}
disableLinkInput={isUploading || youtubeThumbnailConversionCountdown !== null}
setPublishPostOptions={setPublishPostOptions}
@@ -678,6 +678,41 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('publishes twimg query-format reply links with a path extension', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
// The publish path normalizes the raw `?format=` link even without blurring the field.
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith(expect.objectContaining({ link: 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg' }));
});
it('rewrites twimg query-format reply links to their path-extension form on blur', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
await act(async () => {
linkInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg');
});
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
const youtubeLink = 'https://youtu.be/reply123';
const thumbnailLink = 'https://img.youtube.com/vi/reply123/0.jpg';
+17 -2
View File
@@ -3,7 +3,8 @@ 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, getPublishFileDisplayName } from '../../lib/utils/media-link-validation-utils';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions } from '../../lib/utils/media-link-validation-utils';
import { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
import {
type DiceRoll,
@@ -185,7 +186,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
};
useEffect(() => {
@@ -431,6 +432,19 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
};
// Mirror the inline post form: normalize twimg `?format=` links to their `.jpg`/`.png` form once
// the field loses focus, so the conversion that happens at publish time is visible in the input.
const handleLinkBlur = () => {
const currentValue = urlRef.current?.value ?? '';
const twimgPublishUrl = getTwimgMediaFilePublishUrl(currentValue);
if (twimgPublishUrl && twimgPublishUrl !== currentValue) {
if (urlRef.current) {
urlRef.current.value = twimgPublishUrl;
}
setLinkValue(twimgPublishUrl);
}
};
useEffect(() => {
const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current;
@@ -601,6 +615,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
onChange={(e) => {
handleLinkChange(e.target.value);
}}
onBlur={handleLinkBlur}
/>
</div>
{showOekakiControls && (
+15 -1
View File
@@ -1,5 +1,6 @@
import { Comment } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkHostname, getPublishURLFilename } from './url-utils';
import { getLinkMediaInfo } from './media-utils';
import { getLinkMediaInfo, getTwimgMediaFilePublishUrl } from './media-utils';
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
@@ -23,3 +24,16 @@ export const getPublishFileDisplayName = (url: string, uploadedFileName: string
}
return getPublishURLFilename(url) || uploadedFileName || null;
};
// Build the published comment's `link`, normalizing pbs.twimg.com `?format=` media URLs to their
// direct `.jpg`/`.png` form. `includeCurrentLink` carries through a link that another conversion
// (e.g. the YouTube thumbnail conversion) has already written into the field. Shared by the inline
// post form and the reply modal so the two publish paths cannot drift apart.
export const getPublishLinkOptions = (link: string, includeCurrentLink: boolean): Partial<Pick<Comment, 'link'>> => {
const twimgPublishUrl = getTwimgMediaFilePublishUrl(link);
if (twimgPublishUrl) {
return { link: twimgPublishUrl };
}
return includeCurrentLink && link ? { link } : {};
};
+22 -15
View File
@@ -31,21 +31,28 @@ registerRoute(
}),
);
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
(runtimeAssetDestinations.has(request.destination) || runtimeAssetPathPrefixes.some((prefix) => url.pathname.startsWith(prefix))),
new StaleWhileRevalidate({
cacheName: 'runtime-static-assets',
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true,
}),
],
}),
);
// Runtime-cache hashed build assets, but only in production. In dev the service worker
// would otherwise serve stale first-party `/src` modules through stale-while-revalidate:
// dev modules have no content hash, so the cached copy keeps being served after edits and
// code changes silently appear not to take effect. Production assets are content-hashed,
// so caching them stays safe.
if (import.meta.env.PROD) {
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
(runtimeAssetDestinations.has(request.destination) || runtimeAssetPathPrefixes.some((prefix) => url.pathname.startsWith(prefix))),
new StaleWhileRevalidate({
cacheName: 'runtime-static-assets',
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true,
}),
],
}),
);
}
// Standard SW lifecycle methods
self.skipWaiting();