mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post form): respect reply link rules
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ interface DirectoryFeatures {
|
||||
hasFlags?: boolean;
|
||||
requirePostLink?: boolean;
|
||||
requirePostLinkIsMedia?: boolean;
|
||||
requireReplyLink?: boolean;
|
||||
requireReplyLinkIsMedia?: boolean;
|
||||
noReplyLinks?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
Reference in New Issue
Block a user