feat(flags): add comment flags (#1140)

* feat(flags): add comment flags

* fix(flags): address review feedback

* fix(flags): clear stale flag publish data
This commit is contained in:
Tommaso Casaburi
2026-05-24 23:14:36 +07:00
committed by GitHub
parent 804c14fc35
commit f74b33f43b
66 changed files with 1293 additions and 74 deletions
@@ -438,6 +438,63 @@ describe('ChallengeModal', () => {
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('advances through multiple iframe challenges before publishing answers', async () => {
const publication = createPublication();
testState.challenges = [
createStoredChallenge(
[
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
challenge: 'https://flags.5chan.app/iframe/session-flag',
type: 'url/iframe',
},
],
publication,
),
];
await renderModal();
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'challengeAnswer',
challengeAnswers: [''],
sessionId: 'session-123',
},
origin: 'https://spamblocker.bitsocial.net',
}),
);
});
expect(publication.publishChallengeAnswers).not.toHaveBeenCalled();
expect(testState.removeChallengeMock).not.toHaveBeenCalled();
expect(container.textContent).toContain('mu wants to open flags.5chan.app.');
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'challengeAnswer',
challengeAnswers: [''],
sessionId: 'session-flag',
},
origin: 'https://flags.5chan.app',
}),
);
});
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['', '']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('ignores iframe completion messages with the wrong session id', async () => {
const publication = createPublication();
testState.challenges = [
@@ -373,19 +373,41 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
closeModal();
};
const completeCurrentChallenge = useCallback(
(challengeAnswers: string[]) => {
if (!publication) return;
const resolvedAnswers = challengeAnswers.length ? challengeAnswers : [''];
const updatedAnswers = [...answers];
resolvedAnswers.forEach((answer, index) => {
updatedAnswers[currentChallengeIndex + index] = answer;
});
const nextChallengeIndex = currentChallengeIndex + resolvedAnswers.length;
if (challenges?.[nextChallengeIndex]) {
setAnswers(updatedAnswers);
setCurrentChallengeIndex(nextChallengeIndex);
setReadyIframeChallengeKey('');
return;
}
publication.publishChallengeAnswers(updatedAnswers);
setAnswers([]);
closeModal();
},
[answers, challenges, closeModal, currentChallengeIndex, publication],
);
const onIframeDone = useCallback(() => {
if (!publication) return;
publication.publishChallengeAnswers(['']);
closeModal();
}, [closeModal, publication]);
completeCurrentChallenge(['']);
}, [completeCurrentChallenge, publication]);
const onIframeAutoComplete = useCallback(
(challengeAnswers: string[]) => {
if (!publication) return;
publication.publishChallengeAnswers(challengeAnswers);
closeModal();
completeCurrentChallenge(challengeAnswers);
},
[closeModal, publication],
[completeCurrentChallenge, publication],
);
const onIframeReady = useCallback(() => {
+39
View File
@@ -0,0 +1,39 @@
import { getCommentFlagFlairs, getAuthorFlagViewModels } from '../lib/comment-flags';
import styles from '../views/post/post.module.css';
interface PostAuthorFlagsProps {
author: unknown;
comment: unknown;
enabled: boolean;
}
const getBackgroundPosition = (x: number, y: number) => `${x === 0 ? 0 : -x}px ${y === 0 ? 0 : -y}px`;
const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => {
if (!enabled) return null;
const flags = getAuthorFlagViewModels(getCommentFlagFlairs(comment, author));
if (flags.length === 0) return null;
return (
<span className={styles.authorFlags}>
{flags.map((flag) => (
<span
key={flag.key}
aria-label={flag.label}
className={styles.authorFlag}
role='img'
style={{
backgroundImage: `url("${flag.spritePath}")`,
backgroundPosition: getBackgroundPosition(flag.x, flag.y),
width: flag.width,
height: flag.height,
}}
title={flag.label}
/>
))}
</span>
);
};
export default PostAuthorFlags;
@@ -31,6 +31,7 @@ import EditMenu from '../edit-menu/edit-menu';
import FailedPublishNotice from '../failed-publish-notice';
import { canEmbed } from '../embed';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostMenuDesktop from './post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -239,7 +240,9 @@ const PostInfo = ({
const isReply = parentCid;
const { showOmittedReplies } = useShowOmittedReplies();
const directories = useDirectories();
const directoryEntry = communityAddress ? findDirectoryByAddress(directories, communityAddress) : undefined;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const showAuthorFlags = directoryEntry?.features?.hasFlags === true && !(deleted || removed || purged);
const postMenuProps = selectPostMenuProps(post);
const params = useParams();
@@ -415,6 +418,7 @@ const PostInfo = ({
</>
)}
</span>
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
<>
@@ -20,7 +20,7 @@ const testState = vi.hoisted(() => ({
directories: [
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
] as Array<{ address: string; features?: Record<string, unknown>; title?: string }>,
] as Array<{ address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
gifFrameStatus: 'idle' as 'idle' | 'ready',
handleUploadMock: vi.fn(),
@@ -426,6 +426,7 @@ describe('PostForm', () => {
testState.comments = {};
testState.directories = [
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
@@ -606,6 +607,59 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBeUndefined();
});
it('shows a 4chan-style flag field on flag boards and publishes the default geographic request', async () => {
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
await renderPostForm('/pol');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
expect(flagSelect).toBeTruthy();
expect(flagSelect?.value).toBe('country:auto');
expect(
Array.from(flagSelect?.options || [])
.slice(0, 4)
.map((option) => option.textContent),
).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist', 'Black Nationalist']);
await dispatchInput(textarea as HTMLTextAreaElement, 'flagged post');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'flagged post',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it('publishes selected political flags from the post form', async () => {
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
await renderPostForm('/pol');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const flagSelect = table?.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchChange(flagSelect as HTMLSelectElement, 'pol:AC');
await dispatchInput(textarea as HTMLTextAreaElement, 'memeflag post');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'memeflag post',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
},
flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }],
});
});
it('validates unsupported options and stores fortune output in post content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'random-nsfw.bso';
@@ -69,7 +69,7 @@
position: relative;
}
.postFormTable input[type="text"], .postFormTable textarea {
.postFormTable input[type="text"], .postFormTable textarea, .postFormTable select {
border: var(--post-form-field-input-border, revert);
font-family: var(--post-form-field-font-family, revert);
appearance: var(--post-form-field-input-appearance, revert);
@@ -80,7 +80,7 @@
font-size: 10pt;
}
.postFormTable input[type="text"]:focus, .postFormTable textarea:focus {
.postFormTable input[type="text"]:focus, .postFormTable textarea:focus, .postFormTable select:focus {
border: var(--post-form-field-input-focus-border, revert);
}
@@ -113,6 +113,10 @@
width: 190px !important;
}
.flagSelect {
width: 254px;
}
.spoilerButton input[type="checkbox"] {
margin: 0 3px;
vertical-align: middle;
+38 -2
View File
@@ -23,6 +23,7 @@ import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath } from '../../lib/utils/route-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityField } from '../../hooks/use-stable-community';
@@ -124,6 +125,7 @@ interface PostFormFieldsProps {
postCid: string;
subjectRef: React.Ref<HTMLInputElement>;
optionsRef: React.RefObject<HTMLInputElement>;
flagRef: React.RefObject<HTMLSelectElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
@@ -148,6 +150,7 @@ interface PostFormFieldsProps {
communityAddress: string | undefined;
rulesPath: string;
requirePostLinkIsMedia: boolean;
flagOptions: CommentFlagSelectOption[];
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void;
@@ -166,6 +169,7 @@ const PostFormFields = ({
postCid,
subjectRef,
optionsRef,
flagRef,
textRef,
urlRef,
url,
@@ -190,6 +194,7 @@ const PostFormFields = ({
communityAddress,
rulesPath,
requirePostLinkIsMedia,
flagOptions,
showBbcodeToolbar,
onBbcodePreviewToggle,
onPublishReply,
@@ -292,6 +297,26 @@ const PostFormFields = ({
{lengthError && <div className={styles.error}>{lengthError}</div>}
</td>
</tr>
{flagOptions.length > 0 && (
<tr>
<td>{t('flag')}</td>
<td>
<select
key={flagOptions.map((option) => option.value).join('|')}
aria-label={t('flag')}
className={styles.flagSelect}
ref={flagRef}
defaultValue={flagOptions[0]?.value}
>
{flagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
</tr>
)}
<tr>
<td>{requirePostLinkIsMedia ? t('link_to_file') : t('link')}</td>
<td className={styles.linkField}>
@@ -420,6 +445,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const urlRef = useRef<HTMLInputElement>(null);
const subjectRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
@@ -438,6 +464,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
@@ -483,6 +510,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
if (optionsRef.current) {
optionsRef.current.value = '';
}
if (flagRef.current) {
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
}
checkContentLength.cancel();
checkPostOptions.cancel();
fortuneEntryRef.current = null;
@@ -542,8 +572,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent });
publishPost({ content: publishContent, ...flagPublishOptions });
};
// redirect to pending page when pending comment is created
@@ -652,8 +684,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent });
publishReply({ content: publishContent, ...flagPublishOptions });
};
useEffect(() => {
@@ -712,6 +746,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
postCid={postCid}
subjectRef={subjectRef}
optionsRef={optionsRef}
flagRef={flagRef}
textRef={textRef}
urlRef={urlRef}
url={url}
@@ -736,6 +771,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
communityAddress={communityAddress}
rulesPath={rulesPath}
requirePostLinkIsMedia={requirePostLinkIsMedia}
flagOptions={flagOptions}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
@@ -28,6 +28,7 @@ import CommentContent from '../comment-content';
import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostMenuMobile from './post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -87,6 +88,8 @@ const PostInfoAndMedia = ({
const archived = isCommentArchived(resolvedPost);
const purged = resolvedPost?.commentModeration?.purged;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const directoryEntry = communityAddress ? findDirectoryByAddress(directories, communityAddress) : undefined;
const showAuthorFlags = directoryEntry?.features?.hasFlags === true && !(deleted || removed || purged);
const displayBoardPath =
boardPath && communityAddress
? boardPath !== communityAddress
@@ -348,6 +351,7 @@ const PostInfoAndMedia = ({
){' '}
</>
)}
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
{pinned && (
<span className={styles.stickyIconWrapper}>
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
@@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({
features: {},
title: '/mu/ - Music',
},
} as Record<string, { address: string; features?: Record<string, unknown>; title?: string }>,
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
handleUploadMock: vi.fn(),
isMobile: false,
isResolvingExternalQuotes: false,
@@ -322,6 +322,12 @@ describe('ReplyModal', () => {
features: {},
title: '/mu/ - Music',
},
'politically-incorrect.bso': {
address: 'politically-incorrect.bso',
directoryCode: 'pol',
features: { hasFlags: true },
title: '/pol/ - Politically Incorrect',
},
'random-nsfw.bso': {
address: 'random-nsfw.bso',
features: {},
@@ -424,6 +430,52 @@ describe('ReplyModal', () => {
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
});
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
expect(flagSelect).toBeTruthy();
expect(flagSelect?.value).toBe('country:auto');
expect(
Array.from(flagSelect?.options || [])
.slice(0, 3)
.map((option) => option.textContent),
).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist']);
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: '>>42\nselected text',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
});
it('publishes selected political flags from the reply modal', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
await dispatchInput(container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement, 'reply body');
await act(async () => {
if (flagSelect) {
flagSelect.value = 'pol:AC';
flagSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await clickButtonByText('post');
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: 'reply body',
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
},
flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }],
});
});
it('uses the shared loading ellipsis while a reply upload is running', async () => {
testState.isUploading = true;
@@ -39,7 +39,7 @@
background-image: var(--close-button-background-image);
}
.container input[type="text"], .container textarea {
.container input[type="text"], .container textarea, .container select {
border: var(--reply-modal-field-input-border, revert);
font-family: var(--post-form-field-font-family, revert);
appearance: var(--post-form-field-input-appearance, revert);
@@ -53,7 +53,7 @@
margin-bottom: 1px;
}
.container input[type="text"]:focus, .container textarea:focus {
.container input[type="text"]:focus, .container textarea:focus, .container select:focus {
border: var(--reply-modal-field-input-border-focus, revert);
}
@@ -139,7 +139,7 @@
cursor: default;
}
.container input[type="text"], .container textarea {
.container input[type="text"], .container textarea, .container select {
font-size: 16px;
}
}
+17 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection } from '../../lib/comment-flag-selection';
import {
type DiceRoll,
type FortuneEntry,
@@ -61,6 +62,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
usePublishReply({
cid: parentCid,
@@ -86,6 +88,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
});
const urlRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
@@ -163,9 +166,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsFromSelection(flagRef.current?.value);
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent });
publishReply({ content: publishContent, ...flagPublishOptions });
};
useEffect(() => {
@@ -512,6 +517,17 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}}
/>
</div>
{flagOptions.length > 0 && (
<div>
<select key={flagOptions.map((option) => option.value).join('|')} aria-label={t('flag')} ref={flagRef} defaultValue={flagOptions[0]?.value}>
{flagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
<div className={styles.link}>
<input
type='text'