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
@@ -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'