fix(post form): respect reply link rules

This commit is contained in:
Tommaso Casaburi
2026-06-29 09:55:55 +07:00
parent 3b3a5a5e6c
commit 69ba4ace7a
42 changed files with 476 additions and 93 deletions
@@ -1783,6 +1783,93 @@ describe('PostForm', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('does not require an inline reply link when only post links are required', async () => {
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
},
};
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true },
};
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;
await dispatchInput(textarea, 'Reply body');
await clickByText(table, 'post');
expect(container.textContent).not.toContain('post_link_required_alert');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires an inline reply link when reply links are required', async () => {
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
},
};
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requireReplyLink: true },
};
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.querySelector<HTMLInputElement>('input[aria-label="link"]') as HTMLInputElement;
await dispatchInput(textarea, 'Reply body');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: reply_link_required_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await dispatchInput(linkInput, 'https://example.com/reply');
await clickByText(table, 'post');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/reply' });
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith(expect.objectContaining({ content: 'Reply body' }));
});
it('rejects inline reply links when reply links are disabled', async () => {
testState.comments = {
'thread-cid': {
postCid: 'thread-cid',
},
};
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { noReplyLinks: true },
};
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.querySelector<HTMLInputElement>('input[aria-label="link"]') as HTMLInputElement;
expect(linkInput.disabled).toBe(true);
linkInput.disabled = false;
await dispatchInput(textarea, 'Reply body');
await dispatchInput(linkInput, 'https://example.com/reply.png');
await clickByText(table, 'post');
expect(container.textContent).toContain('error: reply_links_not_allowed_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
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';
+43 -19
View File
@@ -7,7 +7,16 @@ import getShortAddress from '../../lib/get-short-address';
import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions, isPublishFileMediaType } from '../../lib/utils/media-link-validation-utils';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../../lib/utils/publish-link-requirements';
import {
getEffectivePostLinkFeatures,
getEffectiveReplyLinkFeatures,
getNoReplyLinks,
getPublishLinkValidationError,
getRequirePostLink,
getRequirePostLinkIsMedia,
getRequireReplyLink,
getRequireReplyLinkIsMedia,
} from '../../lib/utils/publish-link-requirements';
import {
type DiceRoll,
type FortuneEntry,
@@ -177,7 +186,7 @@ interface PostFormFieldsProps {
subscriptions: string[];
communityAddress: string | undefined;
rulesPath: string;
requirePostLinkIsMedia: boolean;
requireCurrentLinkIsMedia: boolean;
flagOptions: CommentFlagSelectOption[];
flashTagOptions: FlashTagOption[];
showFlashTagSelector: boolean;
@@ -232,7 +241,7 @@ const PostFormFields = ({
subscriptions,
communityAddress,
rulesPath,
requirePostLinkIsMedia,
requireCurrentLinkIsMedia,
flagOptions,
flashTagOptions,
showFlashTagSelector,
@@ -367,15 +376,15 @@ const PostFormFields = ({
</tr>
)}
<tr>
<td>{requirePostLinkIsMedia ? t('link_to_file') : t('link')}</td>
<td>{requireCurrentLinkIsMedia ? t('link_to_file') : t('link')}</td>
<td className={styles.linkField}>
<input
type='text'
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
aria-label={requireCurrentLinkIsMedia ? t('link_to_file') : t('link')}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
placeholder={requireCurrentLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
ref={urlRef}
disabled={disableLinkInput}
onChange={(e) => {
@@ -383,7 +392,7 @@ const PostFormFields = ({
}}
onBlur={handleLinkBlur}
/>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} requireFile={requirePostLinkIsMedia} />}</span>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} requireFile={requireCurrentLinkIsMedia} />}</span>
</td>
</tr>
{showUploadControls && (
@@ -402,8 +411,12 @@ const PostFormFields = ({
isUploading={isUploading}
showUploadControls={showUploadControls}
/>
<span title={getPublishFileDisplayName(url, uploadedFileName, requirePostLinkIsMedia) || undefined}>
{isUploading ? <LoadingEllipsis string={t('uploading')} /> : getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'), requirePostLinkIsMedia)}
<span title={getPublishFileDisplayName(url, uploadedFileName, requireCurrentLinkIsMedia) || undefined}>
{isUploading ? (
<LoadingEllipsis string={t('uploading')} />
) : (
getPostFormFileDisplayLabel(url, uploadedFileName, t('no_file_chosen'), requireCurrentLinkIsMedia)
)}
</span>
</td>
</tr>
@@ -577,9 +590,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const communityFeatures = useCommunityField(effectiveBoardAddress, (community) => community?.features);
const publishLinkFeatures = getEffectivePublishLinkFeatures(communityFeatures, directoryEntry?.features);
const requirePostLink = getRequirePostLink(publishLinkFeatures);
const requirePostLinkIsMedia = getRequirePostLinkIsMedia(publishLinkFeatures, isInAllView || isInSubscriptionsView);
const postLinkFeatures = getEffectivePostLinkFeatures(communityFeatures, directoryEntry?.features);
const replyLinkFeatures = getEffectiveReplyLinkFeatures(communityFeatures, directoryEntry?.features);
const requirePostLink = getRequirePostLink(postLinkFeatures);
const requirePostLinkIsMedia = getRequirePostLinkIsMedia(postLinkFeatures, isInAllView || isInSubscriptionsView);
const requireReplyLink = getRequireReplyLink(replyLinkFeatures);
const requireReplyLinkIsMedia = getRequireReplyLinkIsMedia(replyLinkFeatures, isInAllView || isInSubscriptionsView);
const noReplyLinks = getNoReplyLinks(replyLinkFeatures);
const requireCurrentLinkIsMedia = isInPostView ? requireReplyLinkIsMedia : requirePostLinkIsMedia;
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
@@ -687,6 +705,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
requiredLinkAlertKey: 'post_link_required_alert',
requiredMediaLinkAlertKey: 'post_media_link_required_alert',
t,
});
if (linkValidationError) {
@@ -816,8 +836,12 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const linkValidationError = getPublishLinkValidationError({
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
noLinks: noReplyLinks,
requireLink: requireReplyLink,
requireMedia: requireReplyLinkIsMedia,
requiredLinkAlertKey: 'reply_link_required_alert',
requiredMediaLinkAlertKey: 'reply_media_link_required_alert',
noLinksAlertKey: 'reply_links_not_allowed_alert',
t,
});
if (linkValidationError) {
@@ -856,7 +880,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
enabled: requireCurrentLinkIsMedia && !(isInPostView && noReplyLinks),
onContentChange: handleContentValueChange,
onLinkChange: setLinkValue,
textRef,
@@ -954,13 +978,13 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
handleLinkChange={handleLinkChange}
handleLinkBlur={handleLinkBlur}
handleOptionsChange={handleOptionsChange}
disableLinkInput={isUploading || youtubeThumbnailConversionCountdown !== null}
disableLinkInput={isUploading || youtubeThumbnailConversionCountdown !== null || (isInPostView && noReplyLinks)}
setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions}
isUploading={isUploading}
uploadedFileName={uploadedFileName}
showUploadControls={showUploadControls}
showOekakiControls={showOekakiControls}
showUploadControls={showUploadControls && !(isInPostView && noReplyLinks)}
showOekakiControls={showOekakiControls && !(isInPostView && noReplyLinks)}
showSpoilerForPost={showSpoilerForPost}
showSpoilerForReply={showSpoilerForReply}
isInAllView={isInAllView}
@@ -971,7 +995,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
subscriptions={subscriptions}
communityAddress={communityAddress}
rulesPath={rulesPath}
requirePostLinkIsMedia={requirePostLinkIsMedia}
requireCurrentLinkIsMedia={requireCurrentLinkIsMedia}
flagOptions={flagOptions}
flashTagOptions={FLASH_TAG_OPTIONS}
showFlashTagSelector={showFlashTagSelector}
@@ -530,7 +530,7 @@ describe('ReplyModal', () => {
testState.directoryByAddress['oekaki-posting.bso'] = {
address: 'oekaki-posting.bso',
directoryCode: 'i',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
features: { requireReplyLink: true, requireReplyLinkIsMedia: true },
title: '/i/ - Oekaki',
};
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
@@ -765,7 +765,7 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires a link when live community features require post links', async () => {
it('does not require a reply link when only live community features require post links', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
@@ -777,8 +777,24 @@ describe('ReplyModal', () => {
await clickButtonByText('post');
expect(container.textContent).toContain('error: post_link_required_alert');
const linkRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_link_required_alert');
expect(container.textContent).not.toContain('post_link_required_alert');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires a link when live community features require reply links', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requireReplyLink: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).toContain('error: reply_link_required_alert');
const linkRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: reply_link_required_alert');
expect(linkRequiredError?.className).toContain('error');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
@@ -790,20 +806,20 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
it('requires a media link when live community features require post links to be media', async () => {
it('requires a media link when live community features require reply links to be media', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
features: { requireReplyLink: true, requireReplyLinkIsMedia: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).toContain('error: post_media_link_required_alert');
const mediaRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: post_media_link_required_alert');
expect(container.textContent).toContain('error: reply_media_link_required_alert');
const mediaRequiredError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'error: reply_media_link_required_alert');
expect(mediaRequiredError?.className).toContain('error');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
@@ -826,14 +842,14 @@ describe('ReplyModal', () => {
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLink: false, requirePostLinkIsMedia: true },
features: { requireReplyLink: false, requireReplyLinkIsMedia: true },
};
await renderReplyModal('/mu/thread/post-1');
await clickButtonByText('post');
expect(container.textContent).not.toContain('post_media_link_required_alert');
expect(container.textContent).not.toContain('reply_media_link_required_alert');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
testState.publishReplyMock.mockClear();
@@ -845,6 +861,27 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('rejects reply links when live community features disallow reply links', async () => {
testState.openEmpty = true;
testState.selectedText = 'Reply body';
testState.communities['music-posting.eth'] = {
address: 'music-posting.eth',
features: { noReplyLinks: true },
};
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
expect(linkInput.disabled).toBe(true);
linkInput.disabled = false;
await dispatchInput(linkInput, 'https://example.com/reply.png');
await clickButtonByText('post');
expect(container.textContent).toContain('error: reply_links_not_allowed_alert');
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('publishes twimg query-format reply links with a path extension', async () => {
testState.openEmpty = true;
testState.selectedText = 'reply body';
@@ -887,7 +924,7 @@ describe('ReplyModal', () => {
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
features: { requireReplyLinkIsMedia: true },
title: '/mu/ - Music',
};
@@ -930,7 +967,7 @@ describe('ReplyModal', () => {
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
features: { requireReplyLinkIsMedia: true },
title: '/mu/ - Music',
};
@@ -960,7 +997,7 @@ describe('ReplyModal', () => {
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
features: { requireReplyLinkIsMedia: true },
title: '/mu/ - Music',
};
+25 -14
View File
@@ -5,7 +5,13 @@ import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions } from '../../lib/utils/media-link-validation-utils';
import { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../../lib/utils/publish-link-requirements';
import {
getEffectiveReplyLinkFeatures,
getNoReplyLinks,
getPublishLinkValidationError,
getRequireReplyLink,
getRequireReplyLinkIsMedia,
} from '../../lib/utils/publish-link-requirements';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
import {
type DiceRoll,
@@ -79,9 +85,10 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const showTexButton = isMathDirectoryCode(postOptionsDirectoryCode) || isMathDirectoryCode(directoryEntry?.directoryCode);
const communityFeatures = useCommunityField(communityAddress, (community) => community?.features);
const publishLinkFeatures = getEffectivePublishLinkFeatures(communityFeatures, directoryEntry?.features);
const requirePostLink = getRequirePostLink(publishLinkFeatures);
const requirePostLinkIsMedia = getRequirePostLinkIsMedia(publishLinkFeatures, isInAllView || isInSubscriptionsView);
const replyLinkFeatures = getEffectiveReplyLinkFeatures(communityFeatures, directoryEntry?.features);
const requireReplyLink = getRequireReplyLink(replyLinkFeatures);
const requireReplyLinkIsMedia = getRequireReplyLinkIsMedia(replyLinkFeatures, isInAllView || isInSubscriptionsView);
const noReplyLinks = getNoReplyLinks(replyLinkFeatures);
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
usePublishReply({
@@ -188,8 +195,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const linkValidationError = getPublishLinkValidationError({
link: currentUrl,
requireLink: requirePostLink,
requireMedia: requirePostLinkIsMedia,
noLinks: noReplyLinks,
requireLink: requireReplyLink,
requireMedia: requireReplyLinkIsMedia,
requiredLinkAlertKey: 'reply_link_required_alert',
requiredMediaLinkAlertKey: 'reply_media_link_required_alert',
noLinksAlertKey: 'reply_links_not_allowed_alert',
t,
});
if (linkValidationError) {
@@ -444,7 +455,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
enabled: requireReplyLinkIsMedia && !noReplyLinks,
onContentChange: handleConvertedContentChange,
onLinkChange: setLinkValue,
textRef,
@@ -538,7 +549,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
};
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishFileDisplayName(url, uploadedFileName, requirePostLinkIsMedia);
const displayedFileName = getPublishFileDisplayName(url, uploadedFileName, requireReplyLinkIsMedia);
const youtubeThumbnailConversionNotice =
youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null;
@@ -652,22 +663,22 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
<input
type='text'
ref={urlRef}
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading}
aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading || noReplyLinks}
onChange={(e) => {
handleLinkChange(e.target.value);
}}
onBlur={handleLinkBlur}
/>
</div>
{showOekakiControls && (
{showOekakiControls && !noReplyLinks && (
<div className={styles.oekakiRow}>
<span className={styles.oekakiLabel}>Draw</span>
<OekakiDrawingControls className={styles.oekakiControls} disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={handleOekakiClearUploadedUrl} />
</div>
)}
{showOekakiControls && isWebRuntime() ? <div className={styles.oekakiWarning}>{OEKAKI_WEB_WARNING_TEXT}</div> : null}
{showOekakiControls && !noReplyLinks && isWebRuntime() ? <div className={styles.oekakiWarning}>{OEKAKI_WEB_WARNING_TEXT}</div> : null}
{flagOptions.length > 0 && (
<div>
<select
@@ -687,7 +698,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</div>
)}
<div className={styles.footer}>
{showUploadControls && (
{showUploadControls && !noReplyLinks && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button type='button' onClick={handleUpload} disabled={isUploading}>
@@ -1,40 +1,105 @@
import { describe, expect, it } from 'vitest';
import type { TFunction } from 'i18next';
import { getEffectivePublishLinkFeatures, getPublishLinkValidationError, getRequirePostLink, getRequirePostLinkIsMedia } from '../publish-link-requirements';
import {
getEffectivePostLinkFeatures,
getEffectiveReplyLinkFeatures,
getNoReplyLinks,
getPublishLinkValidationError,
getRequirePostLink,
getRequirePostLinkIsMedia,
getRequireReplyLink,
getRequireReplyLinkIsMedia,
} from '../publish-link-requirements';
const t = ((key: string) => key) as TFunction;
describe('getEffectivePublishLinkFeatures', () => {
describe('getEffectivePostLinkFeatures', () => {
it('uses live community feature values before directory defaults', () => {
expect(getEffectivePublishLinkFeatures({ requirePostLink: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
expect(getEffectivePostLinkFeatures({ requirePostLink: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
requirePostLink: false,
requirePostLinkIsMedia: true,
});
});
it('falls back per feature when live community features are partial', () => {
expect(getEffectivePublishLinkFeatures({ requirePostLinkIsMedia: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
expect(getEffectivePostLinkFeatures({ requirePostLinkIsMedia: false }, { requirePostLink: true, requirePostLinkIsMedia: true })).toEqual({
requirePostLink: true,
requirePostLinkIsMedia: false,
});
});
});
describe('getEffectiveReplyLinkFeatures', () => {
it('uses reply features separately from post features', () => {
expect(
getEffectiveReplyLinkFeatures(
{ requirePostLink: true, requirePostLinkIsMedia: true, requireReplyLink: false, noReplyLinks: true },
{ requireReplyLink: true, requireReplyLinkIsMedia: true, noReplyLinks: false },
),
).toEqual({
requireReplyLink: false,
requireReplyLinkIsMedia: true,
noReplyLinks: true,
});
});
});
describe('getPublishLinkValidationError', () => {
it('does not require an empty link when only media links are enabled', () => {
expect(getPublishLinkValidationError({ link: '', requireLink: false, requireMedia: true, t })).toBeNull();
expect(
getPublishLinkValidationError({
link: '',
requireLink: false,
requireMedia: true,
requiredLinkAlertKey: 'post_link_required_alert',
requiredMediaLinkAlertKey: 'post_media_link_required_alert',
t,
}),
).toBeNull();
});
it('requires a media link when both link and media requirements are enabled', () => {
expect(getPublishLinkValidationError({ link: '', requireLink: true, requireMedia: true, t })).toBe('error: post_media_link_required_alert');
expect(
getPublishLinkValidationError({
link: '',
requireLink: true,
requireMedia: true,
requiredLinkAlertKey: 'post_link_required_alert',
requiredMediaLinkAlertKey: 'post_media_link_required_alert',
t,
}),
).toBe('error: post_media_link_required_alert');
});
it('rejects reply links when links are disabled', () => {
expect(
getPublishLinkValidationError({
link: 'https://example.com/reply.png',
noLinks: true,
requireLink: false,
requireMedia: true,
requiredLinkAlertKey: 'reply_link_required_alert',
requiredMediaLinkAlertKey: 'reply_media_link_required_alert',
noLinksAlertKey: 'reply_links_not_allowed_alert',
t,
}),
).toBe('error: reply_links_not_allowed_alert');
});
});
describe('publish link feature helpers', () => {
it('does not treat media-only validation as a required link', () => {
const features = getEffectivePublishLinkFeatures({ requirePostLink: false, requirePostLinkIsMedia: true }, undefined);
const features = getEffectivePostLinkFeatures({ requirePostLink: false, requirePostLinkIsMedia: true }, undefined);
expect(getRequirePostLink(features)).toBe(false);
expect(getRequirePostLinkIsMedia(features, false)).toBe(true);
});
it('does not treat post link requirements as reply link requirements', () => {
const features = getEffectiveReplyLinkFeatures({ requirePostLink: true, requirePostLinkIsMedia: true }, undefined);
expect(getRequireReplyLink(features)).toBe(false);
expect(getRequireReplyLinkIsMedia(features, false)).toBe(false);
expect(getNoReplyLinks(features)).toBe(false);
});
});
+3
View File
@@ -9,6 +9,9 @@ interface DirectoryFeatures {
hasFlags?: boolean;
requirePostLink?: boolean;
requirePostLinkIsMedia?: boolean;
requireReplyLink?: boolean;
requireReplyLinkIsMedia?: boolean;
noReplyLinks?: boolean;
[key: string]: unknown;
}
+56 -5
View File
@@ -5,12 +5,26 @@ import { isValidPublishURL } from './url-utils';
interface PublishLinkFeatures {
requirePostLink?: unknown;
requirePostLinkIsMedia?: unknown;
requireReplyLink?: unknown;
requireReplyLinkIsMedia?: unknown;
noReplyLinks?: unknown;
}
export const getEffectivePublishLinkFeatures = (
interface EffectivePostLinkFeatures {
requirePostLink?: unknown;
requirePostLinkIsMedia?: unknown;
}
interface EffectiveReplyLinkFeatures {
requireReplyLink?: unknown;
requireReplyLinkIsMedia?: unknown;
noReplyLinks?: unknown;
}
export const getEffectivePostLinkFeatures = (
communityFeatures: PublishLinkFeatures | undefined,
directoryFeatures: PublishLinkFeatures | undefined,
): PublishLinkFeatures | undefined => {
): EffectivePostLinkFeatures | undefined => {
const requirePostLink = communityFeatures?.requirePostLink ?? directoryFeatures?.requirePostLink;
const requirePostLinkIsMedia = communityFeatures?.requirePostLinkIsMedia ?? directoryFeatures?.requirePostLinkIsMedia;
@@ -24,24 +38,61 @@ export const getEffectivePublishLinkFeatures = (
};
};
export const getRequirePostLink = (features: PublishLinkFeatures | undefined): boolean => Boolean(features?.requirePostLink);
export const getEffectiveReplyLinkFeatures = (
communityFeatures: PublishLinkFeatures | undefined,
directoryFeatures: PublishLinkFeatures | undefined,
): EffectiveReplyLinkFeatures | undefined => {
const requireReplyLink = communityFeatures?.requireReplyLink ?? directoryFeatures?.requireReplyLink;
const requireReplyLinkIsMedia = communityFeatures?.requireReplyLinkIsMedia ?? directoryFeatures?.requireReplyLinkIsMedia;
const noReplyLinks = communityFeatures?.noReplyLinks ?? directoryFeatures?.noReplyLinks;
export const getRequirePostLinkIsMedia = (features: PublishLinkFeatures | undefined, useMediaDefault: boolean): boolean =>
if (requireReplyLink === undefined && requireReplyLinkIsMedia === undefined && noReplyLinks === undefined) {
return undefined;
}
return {
requireReplyLink,
requireReplyLinkIsMedia,
noReplyLinks,
};
};
export const getRequirePostLink = (features: EffectivePostLinkFeatures | undefined): boolean => Boolean(features?.requirePostLink);
export const getRequirePostLinkIsMedia = (features: EffectivePostLinkFeatures | undefined, useMediaDefault: boolean): boolean =>
Boolean(features?.requirePostLinkIsMedia) || (features?.requirePostLinkIsMedia === undefined && useMediaDefault);
export const getRequireReplyLink = (features: EffectiveReplyLinkFeatures | undefined): boolean => Boolean(features?.requireReplyLink);
export const getRequireReplyLinkIsMedia = (features: EffectiveReplyLinkFeatures | undefined, useMediaDefault: boolean): boolean =>
Boolean(features?.requireReplyLinkIsMedia) || (features?.requireReplyLinkIsMedia === undefined && useMediaDefault);
export const getNoReplyLinks = (features: EffectiveReplyLinkFeatures | undefined): boolean => Boolean(features?.noReplyLinks);
export const getPublishLinkValidationError = ({
link,
noLinks = false,
requireLink,
requireMedia,
requiredLinkAlertKey,
requiredMediaLinkAlertKey,
noLinksAlertKey,
t,
}: {
link: string;
noLinks?: boolean;
requireLink: boolean;
requireMedia: boolean;
requiredLinkAlertKey: string;
requiredMediaLinkAlertKey: string;
noLinksAlertKey?: string;
t: TFunction;
}): string | null => {
if (link && noLinks && noLinksAlertKey) {
return `${t('error')}: ${t(noLinksAlertKey)}`;
}
if (!link && requireLink) {
return `${t('error')}: ${t(requireMedia ? 'post_media_link_required_alert' : 'post_link_required_alert')}`;
return `${t('error')}: ${t(requireMedia ? requiredMediaLinkAlertKey : requiredLinkAlertKey)}`;
}
if (link && !isValidPublishURL(link)) {
return `${t('error')}: ${t('invalid_url_alert')}`;