| {requirePostLinkIsMedia ? t('link_to_file') : t('link')} |
@@ -420,6 +445,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const urlRef = useRef(null);
const subjectRef = useRef(null);
const optionsRef = useRef(null);
+ const flagRef = useRef(null);
const fortuneEntryRef = useRef(null);
const diceRollRef = useRef(null);
const nonokoRedirectPathRef = useRef(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}
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx
index dfb145c3..2f087651 100644
--- a/src/components/post-mobile/post-mobile.tsx
+++ b/src/components/post-mobile/post-mobile.tsx
@@ -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 = ({
){' '}
>
)}
+
{pinned && (
diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx
index c5d6c8c7..9968da43 100644
--- a/src/components/reply-modal/__tests__/reply-modal.test.tsx
+++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx
@@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({
features: {},
title: '/mu/ - Music',
},
- } as Record; title?: string }>,
+ } as Record; 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('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('select[aria-label="flag"]');
+ await dispatchInput(container.querySelector('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;
diff --git a/src/components/reply-modal/reply-modal.module.css b/src/components/reply-modal/reply-modal.module.css
index 2aa34743..99e923e8 100644
--- a/src/components/reply-modal/reply-modal.module.css
+++ b/src/components/reply-modal/reply-modal.module.css
@@ -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;
}
}
diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx
index 72ed41ae..819ed0aa 100644
--- a/src/components/reply-modal/reply-modal.tsx
+++ b/src/components/reply-modal/reply-modal.tsx
@@ -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(null);
const optionsRef = useRef(null);
+ const flagRef = useRef(null);
const fortuneEntryRef = useRef(null);
const diceRollRef = useRef(null);
const nonokoRedirectPathRef = useRef(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
}}
/>
+ {flagOptions.length > 0 && (
+
+
+
+ )}
{
expect(findDirectoryByAddress(communities, 'music-posting.bso')?.address).toBe('music-posting.bso');
expect(findDirectoryByAddress(communities, 'music-posting.eth')?.address).toBe('music-posting.bso');
expect(findDirectoryByAddress(communities, '12D3KooWQdQ6TkVA1Xe9zzaFP6vXBgsLeMAewpLpLwbsAYKivnQy')?.address).toBe('music-posting.bso');
+ expect(findDirectoryByAddress([], '12D3KooWNFgjQWX2EUEs7pixdjkWSLh21EZ9NeYnV8iMaCyYhLGJ')).toBeUndefined();
expect(findDirectoryByAddress(communities, undefined)).toBeUndefined();
});
diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx
index 24f086f2..640703b8 100644
--- a/src/hooks/__tests__/use-publish-post.test.tsx
+++ b/src/hooks/__tests__/use-publish-post.test.tsx
@@ -155,4 +155,30 @@ describe('usePublishPost', () => {
expect(testState.lastPublishOptions?.content).toBe('Fresh body');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
+
+ it('clears stale flag data from one-shot publish options', async () => {
+ await act(async () => {
+ latestValue.setPublishPostOptions({
+ content: 'Flag body',
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
+ },
+ flairs: [{ text: 'flag:country:auto', type: 'country', code: 'auto' }],
+ } as never);
+ });
+
+ await act(async () => {
+ latestValue.publishPost({
+ content: 'Plain body',
+ challengeRequest: undefined,
+ flairs: undefined,
+ } as never);
+ await Promise.resolve();
+ });
+
+ expect(testState.lastPublishOptions?.content).toBe('Plain body');
+ expect(testState.lastPublishOptions?.challengeRequest).toBeUndefined();
+ expect(testState.lastPublishOptions?.flairs).toBeUndefined();
+ expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx
index 92b5d08c..729c221d 100644
--- a/src/hooks/__tests__/use-publish-reply.test.tsx
+++ b/src/hooks/__tests__/use-publish-reply.test.tsx
@@ -83,8 +83,10 @@ describe('usePublishReply', () => {
usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } });
usePublishReplyStore.setState({
author: {},
+ challengeRequest: {},
content: {},
displayName: {},
+ flairs: {},
link: {},
publishCommentOptions: {},
spoiler: {},
@@ -186,6 +188,33 @@ describe('usePublishReply', () => {
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
+ it('clears stale flag data from one-shot reply options', async () => {
+ await act(async () => {
+ latestValue.setPublishReplyOptions({
+ content: 'Flag reply',
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
+ },
+ flairs: [{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }],
+ } as never);
+ });
+
+ await act(async () => {
+ await latestValue.publishReply({
+ content: 'Plain reply',
+ challengeRequest: undefined,
+ flairs: undefined,
+ } as never);
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(testState.lastPublishOptions?.content).toBe('Plain reply');
+ expect(testState.lastPublishOptions?.challengeRequest).toBeUndefined();
+ expect(testState.lastPublishOptions?.flairs).toBeUndefined();
+ expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
+ });
+
it('blocks publish when a same-board external quote cannot be resolved', async () => {
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts
index 148d2267..9f8e33dd 100644
--- a/src/hooks/use-directories.ts
+++ b/src/hooks/use-directories.ts
@@ -136,7 +136,14 @@ export const findDirectoryByAddress = (directories: DirectoryCommunity[], addres
}
const normalizedAddress = normalizeBoardAddress(address);
- return directories.find((community) => getDirectoryIdentifiers(community).some((identifier) => normalizeBoardAddress(identifier) === normalizedAddress));
+ const normalizedMatch = directories.find((community) =>
+ getDirectoryIdentifiers(community).some((identifier) => normalizeBoardAddress(identifier) === normalizedAddress),
+ );
+ if (normalizedMatch) {
+ return normalizedMatch;
+ }
+
+ return undefined;
};
const adaptV1Communities = (value: Record): DirectoryCommunity[] => {
diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts
index 31506a14..73dd4330 100644
--- a/src/hooks/use-publish-post.ts
+++ b/src/hooks/use-publish-post.ts
@@ -10,12 +10,13 @@ type UsePublishPostOptions = {
};
const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
- const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore(
+ const { author, title, content, link, flairs, spoiler, publishCommentOptions } = usePublishPostStore(
useShallow((state) => ({
author: state.author,
title: state.title || undefined,
content: state.content || undefined,
link: state.link || undefined,
+ flairs: state.flairs,
spoiler: state.spoiler || false,
publishCommentOptions: state.publishCommentOptions,
})),
@@ -39,7 +40,9 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
title,
content,
link,
+ flairs,
spoiler,
+ ...(publishCommentOptions.challengeRequest ? { challengeRequest: publishCommentOptions.challengeRequest } : {}),
};
const displayName = author?.displayName;
@@ -48,7 +51,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
}
return baseOptions;
- }, [author, content, link, spoiler, communityAddress, title]);
+ }, [author, content, flairs, link, spoiler, communityAddress, title, publishCommentOptions.challengeRequest]);
const setPublishPostOptions = useCallback(
(options: Partial) => {
@@ -90,7 +93,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
useEffect(() => {
setPublishPostError(null);
- }, [author?.displayName, blockedReason, communityAddress, content, link, spoiler, title]);
+ }, [author?.displayName, blockedReason, communityAddress, content, flairs, link, spoiler, title]);
const startPublishPost = useCallback(() => {
if (blockedReason) {
diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts
index 92c3c33d..46061232 100644
--- a/src/hooks/use-publish-reply.ts
+++ b/src/hooks/use-publish-reply.ts
@@ -23,11 +23,12 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
const account = useAccount();
const directories = useDirectories();
- const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore(
+ const { author, content, link, flairs, spoiler, publishCommentOptions } = usePublishReplyStore(
useShallow((state) => ({
author: state.author[parentCid],
content: state.content[parentCid] || undefined,
link: state.link[parentCid] || undefined,
+ flairs: state.flairs[parentCid],
spoiler: state.spoiler[parentCid] || false,
publishCommentOptions: state.publishCommentOptions[parentCid],
})),
@@ -57,7 +58,9 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
postCid: postCid ?? parentCid,
content,
link,
+ flairs,
spoiler,
+ ...(publishCommentOptions?.challengeRequest ? { challengeRequest: publishCommentOptions.challengeRequest } : {}),
};
const displayName = author?.displayName;
@@ -66,7 +69,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
}
return baseOptions;
- }, [author, content, link, parentCid, postCid, spoiler, communityAddress]);
+ }, [author, content, flairs, link, parentCid, postCid, spoiler, communityAddress, publishCommentOptions?.challengeRequest]);
const setPublishReplyOptions = useCallback(
(options: Partial) => {
@@ -142,7 +145,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
setPublishReplyError(null);
setPublishReplyStateMessage(null);
setIsResolvingExternalQuotes(false);
- }, [blockedReason, content, communityAddress]);
+ }, [blockedReason, content, communityAddress, flairs]);
useEffect(() => {
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
diff --git a/src/lib/__tests__/board-flags.test.ts b/src/lib/__tests__/board-flags.test.ts
new file mode 100644
index 00000000..83a39c2e
--- /dev/null
+++ b/src/lib/__tests__/board-flags.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { getBoardFlagDefinition, normalizeBoardFlagKind } from '../board-flags';
+
+describe('board-flags', () => {
+ it('normalizes board flag kind aliases', () => {
+ expect(normalizeBoardFlagKind('pol')).toBe('pol');
+ expect(normalizeBoardFlagKind('memeflags')).toBe('pol');
+ expect(normalizeBoardFlagKind('mlp')).toBe('pony');
+ expect(normalizeBoardFlagKind('unknown')).toBeUndefined();
+ });
+
+ it('maps political flags to the flags-2 sprite grid', () => {
+ expect(getBoardFlagDefinition('pol', 'AC')).toMatchObject({
+ code: 'AC',
+ label: 'Anarcho-Capitalist',
+ spritePath: 'assets/icons/flags-2.png',
+ width: 16,
+ height: 12,
+ x: 0,
+ y: 0,
+ });
+ expect(getBoardFlagDefinition('pol', 'WP')).toMatchObject({ code: 'WP', x: 64, y: 48 });
+ });
+
+ it('maps pony flags to the flags-pony sprite grid', () => {
+ expect(getBoardFlagDefinition('pony', '4CC')).toMatchObject({
+ code: '4CC',
+ label: '4cc /mlp/',
+ spritePath: 'assets/icons/flags-pony.png',
+ width: 16,
+ height: 16,
+ x: 0,
+ y: 0,
+ });
+ expect(getBoardFlagDefinition('pony', 'AJ')).toMatchObject({ code: 'AJ', label: 'Applejack', x: 80, y: 0 });
+ expect(getBoardFlagDefinition('pony', 'ZS')).toMatchObject({ code: 'ZS', label: 'Zipp Storm', x: 16, y: 144 });
+ });
+});
diff --git a/src/lib/__tests__/comment-flag-selection.test.ts b/src/lib/__tests__/comment-flag-selection.test.ts
new file mode 100644
index 00000000..fd58c4ed
--- /dev/null
+++ b/src/lib/__tests__/comment-flag-selection.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection, getCommentFlagRequestFromSelection } from '../comment-flag-selection';
+
+describe('comment-flag-selection', () => {
+ it('does not expose options for boards without flags', () => {
+ expect(getCommentFlagOptionsForDirectory({ features: {}, title: '/mu/ - Music' })).toEqual([]);
+ });
+
+ it('uses geographic location as the default for country flag boards', () => {
+ expect(
+ getCommentFlagOptionsForDirectory({
+ directoryCode: 'int',
+ features: { hasFlags: true },
+ title: '/int/ - International',
+ }),
+ ).toEqual([{ label: 'Geographic Location', value: 'country:auto' }]);
+ });
+
+ it('matches the 4chan /pol/ flag order', () => {
+ const options = getCommentFlagOptionsForDirectory({
+ directoryCode: 'pol',
+ features: { hasFlags: true },
+ title: '/pol/ - Politically Incorrect',
+ });
+
+ expect(options.slice(0, 6)).toEqual([
+ { label: 'Geographic Location', value: 'country:auto' },
+ { label: 'Anarcho-Capitalist', value: 'pol:AC' },
+ { label: 'Anarchist', value: 'pol:AN' },
+ { label: 'Black Nationalist', value: 'pol:BL' },
+ { label: 'Confederate', value: 'pol:CF' },
+ { label: 'Communist', value: 'pol:CM' },
+ ]);
+ });
+
+ it('matches the 4chan /mlp/ flag default', () => {
+ const options = getCommentFlagOptionsForDirectory({
+ directoryCode: 'mlp',
+ features: { hasFlags: true },
+ title: '/mlp/ - Pony',
+ });
+
+ expect(options.slice(0, 4)).toEqual([
+ { label: 'None', value: 'none' },
+ { label: '4cc /mlp/', value: 'pony:4CC' },
+ { label: 'Adagio Dazzle', value: 'pony:ADA' },
+ { label: 'Anon', value: 'pony:AN' },
+ ]);
+ });
+
+ it('falls back to the board title when directoryCode is blank', () => {
+ const options = getCommentFlagOptionsForDirectory({
+ directoryCode: ' ',
+ features: { hasFlags: true },
+ title: '/mlp/ - Pony',
+ });
+
+ expect(options.slice(0, 2)).toEqual([
+ { label: 'None', value: 'none' },
+ { label: '4cc /mlp/', value: 'pony:4CC' },
+ ]);
+ });
+
+ it('serializes selected flags as challenge-readable flag requests', () => {
+ expect(getCommentFlagRequestFromSelection('country:auto')).toEqual({
+ type: 'country',
+ code: 'auto',
+ text: 'flag:country:auto',
+ });
+ expect(getCommentFlagRequestFromSelection('pol:AC')).toEqual({
+ type: 'pol',
+ code: 'AC',
+ text: 'flag:pol:AC',
+ });
+ expect(getCommentFlagRequestFromSelection('pony:AJ')).toEqual({
+ type: 'pony',
+ code: 'AJ',
+ text: 'flag:pony:AJ',
+ });
+ expect(getCommentFlagRequestFromSelection('none')).toBeUndefined();
+ });
+
+ it('publishes selected flags as signed comment flairs and challenge answers', () => {
+ expect(getCommentFlagPublishOptionsFromSelection('country:auto')).toEqual({
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
+ },
+ flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
+ });
+ expect(getCommentFlagPublishOptionsFromSelection('pony:AJ')).toEqual({
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:pony:AJ'],
+ },
+ flairs: [{ type: 'pony', code: 'AJ', text: 'flag:pony:AJ' }],
+ });
+ });
+
+ it('clears flag publish data when no flag is selected', () => {
+ expect(getCommentFlagPublishOptionsFromSelection('none')).toEqual({
+ challengeRequest: undefined,
+ flairs: undefined,
+ });
+ expect(getCommentFlagPublishOptionsFromSelection(undefined)).toEqual({
+ challengeRequest: undefined,
+ flairs: undefined,
+ });
+ });
+});
diff --git a/src/lib/__tests__/comment-flags.test.ts b/src/lib/__tests__/comment-flags.test.ts
new file mode 100644
index 00000000..7c467d3c
--- /dev/null
+++ b/src/lib/__tests__/comment-flags.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest';
+import { getCommentFlagFlairs, getAuthorFlagFlairs, getAuthorFlagViewModels } from '../comment-flags';
+
+describe('comment-flags', () => {
+ it('parses the pkc-js challenge flair example for country flags', () => {
+ const [flag] = getAuthorFlagViewModels([{ text: 'US-emoji' }], 1000);
+
+ expect(flag).toMatchObject({
+ key: 'country:us',
+ type: 'country',
+ code: 'us',
+ spritePath: 'assets/icons/flags-1.png',
+ width: 16,
+ height: 11,
+ x: 240,
+ y: 154,
+ });
+ });
+
+ it('parses namespaced country, political, and pony flag flairs', () => {
+ const flags = getAuthorFlagViewModels([{ text: 'flag:country:DE' }, { text: 'flag:pol:AC' }, { text: 'flag:pony:AJ' }], 1000);
+
+ expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']);
+ expect(flags[1]).toMatchObject({ label: 'Anarcho-Capitalist', x: 0, y: 0 });
+ expect(flags[2]).toMatchObject({ label: 'Applejack', x: 80, y: 0 });
+ });
+
+ it('normalizes text flag kinds before parsing', () => {
+ const flags = getAuthorFlagViewModels([{ text: 'FLAG:COUNTRY:DE' }, { text: 'POL:AC' }, { text: 'flag:MLP:AJ' }], 1000);
+
+ expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']);
+ });
+
+ it('parses structured flag flair fields', () => {
+ const flags = getAuthorFlagViewModels(
+ [
+ { text: 'ignored', type: 'Country', country: 'GB' },
+ { text: 'ignored', kind: 'MLP', code: 'RD' },
+ ],
+ 1000,
+ );
+
+ expect(flags.map((flag) => flag.key)).toEqual(['country:gb', 'pony:RD']);
+ });
+
+ it('skips expired, duplicate, and unknown flags', () => {
+ const flags = getAuthorFlagViewModels(
+ [{ text: 'flag:country:DE', expiresAt: 999 }, { text: 'flag:country:DE' }, { text: 'DE-emoji' }, { text: 'flag:pony:NOPE' }],
+ 1000,
+ );
+
+ expect(flags.map((flag) => flag.key)).toEqual(['country:de']);
+ });
+
+ it('prefers trusted challenge-written author community flairs', () => {
+ const flairs = getAuthorFlagFlairs({
+ flairs: [{ text: 'flag:pol:AC' }],
+ community: {
+ flairs: [{ text: 'US-emoji' }],
+ },
+ });
+
+ expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:us']);
+ });
+
+ it('reads the signed 5chan flag assertion from the comment', () => {
+ const flairs = getCommentFlagFlairs({
+ '5chan': {
+ country: 'VN',
+ flag: {
+ code: 'VN',
+ text: 'flag:country:vn',
+ type: 'country',
+ },
+ },
+ flairs: [{ code: 'auto', text: 'flag:country:auto', type: 'country' }],
+ author: {
+ community: {
+ flairs: [{ text: 'US-emoji' }],
+ },
+ },
+ });
+
+ expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:vn']);
+ });
+
+ it('falls back to author or post flairs when there is no signed flag assertion', () => {
+ expect(
+ getAuthorFlagViewModels(
+ getCommentFlagFlairs({
+ author: {
+ subplebbit: {
+ flairs: [{ text: 'flag:country:DE' }],
+ },
+ },
+ flairs: [{ text: 'flag:pony:AJ' }],
+ }),
+ ).map((flag) => flag.key),
+ ).toEqual(['country:de']);
+
+ expect(
+ getAuthorFlagViewModels(
+ getCommentFlagFlairs({
+ flairs: [{ text: 'flag:pony:AJ' }],
+ }),
+ ).map((flag) => flag.key),
+ ).toEqual(['pony:AJ']);
+ });
+});
diff --git a/src/lib/board-flags.ts b/src/lib/board-flags.ts
new file mode 100644
index 00000000..9ae003a1
--- /dev/null
+++ b/src/lib/board-flags.ts
@@ -0,0 +1,190 @@
+export type BoardFlagKind = 'pol' | 'pony';
+
+export interface BoardFlagDefinition {
+ kind: BoardFlagKind;
+ code: string;
+ label: string;
+ spritePath: string;
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+}
+
+const POLITICAL_FLAG_WIDTH = 16;
+const POLITICAL_FLAG_HEIGHT = 12;
+const POLITICAL_FLAG_COLUMNS = 5;
+const POLITICAL_FLAG_SPRITE_PATH = 'assets/icons/flags-2.png';
+
+const PONY_FLAG_WIDTH = 16;
+const PONY_FLAG_HEIGHT = 16;
+const PONY_FLAG_COLUMNS = 9;
+const PONY_FLAG_SPRITE_PATH = 'assets/icons/flags-pony.png';
+
+const POLITICAL_FLAG_ENTRIES = [
+ ['AC', 'Anarcho-Capitalist'],
+ ['AN', 'Anarchist'],
+ ['BL', 'Black Nationalist'],
+ ['CF', 'Confederate'],
+ ['CM', 'Communist'],
+ ['CT', 'Catalonia'],
+ ['DM', 'Democrat'],
+ ['EU', 'European'],
+ ['FC', 'Fascist'],
+ ['GN', 'Gadsden'],
+ ['GY', 'Gay'],
+ ['JH', 'Jihadi'],
+ ['KN', 'Kekistani'],
+ ['MF', 'Muslim'],
+ ['NB', 'National Bolshevik'],
+ ['NT', 'NATO'],
+ ['NZ', 'Nazi'],
+ ['PC', 'Hippie'],
+ ['PR', 'Pirate'],
+ ['RE', 'Republican'],
+ ['TM', 'Templar'],
+ ['MZ', 'Task Force Z'],
+ ['TR', 'Tree Hugger'],
+ ['UN', 'United Nations'],
+ ['WP', 'White Supremacist'],
+] as const;
+
+const PONY_FLAG_ENTRIES = [
+ ['4CC', '4cc /mlp/'],
+ ['ADA', 'Adagio Dazzle'],
+ ['AN', 'Anon'],
+ ['ANF', 'Anonfilly'],
+ ['APB', 'Apple Bloom'],
+ ['AJ', 'Applejack'],
+ ['AB', 'Aria Blaze'],
+ ['AU', 'Autumn Blaze'],
+ ['BB', 'Bon Bon'],
+ ['BM', 'Big Mac'],
+ ['BP', 'Berry Punch'],
+ ['BS', 'Babs Seed'],
+ ['CL', 'Changeling'],
+ ['CO', 'Coco Pommel'],
+ ['CG', 'Cozy Glow'],
+ ['CHE', 'Cheerilee'],
+ ['CB', 'Cherry Berry'],
+ ['DAY', 'Daybreaker'],
+ ['DD', 'Daring Do'],
+ ['DER', 'Derpy Hooves'],
+ ['DT', 'Diamond Tiara'],
+ ['DIS', 'Discord'],
+ ['EQA', 'EqG Applejack'],
+ ['EQF', 'EqG Fluttershy'],
+ ['EQP', 'EqG Pinkie Pie'],
+ ['EQR', 'EqG Rainbow Dash'],
+ ['EQT', 'EqG Trixie'],
+ ['EQI', 'EqG Twilight Sparkle'],
+ ['EQS', 'EqG Sunset Shimmer'],
+ ['ERA', 'EqG Rarity'],
+ ['FAU', 'Fausticorn'],
+ ['FLE', 'Fleur de lis'],
+ ['FL', 'Fluttershy'],
+ ['GI', 'Gilda'],
+ ['HT', 'Hitch Trailblazer'],
+ ['IZ', 'Izzy Moonbow'],
+ ['LI', 'Limestone'],
+ ['LT', 'Lord Tirek'],
+ ['LY', 'Lyra Heartstrings'],
+ ['MA', 'Marble'],
+ ['MAU', 'Maud'],
+ ['MIN', 'Minuette'],
+ ['NI', 'Nightmare Moon'],
+ ['NUR', 'Nurse Redheart'],
+ ['OCT', 'Octavia'],
+ ['PAR', 'Parasprite'],
+ ['PC', 'Princess Cadance'],
+ ['PCE', 'Princess Celestia'],
+ ['PI', 'Pinkie Pie'],
+ ['PLU', 'Princess Luna'],
+ ['PM', 'Pinkamena'],
+ ['PP', 'Pipp Petals'],
+ ['QC', 'Queen Chrysalis'],
+ ['RAR', 'Rarity'],
+ ['RD', 'Rainbow Dash'],
+ ['RLU', 'Roseluck'],
+ ['S1L', 'S1 Luna'],
+ ['SCO', 'Scootaloo'],
+ ['SHI', 'Shining Armor'],
+ ['SIL', 'Silver Spoon'],
+ ['SON', 'Sonata Dusk'],
+ ['SP', 'Spike'],
+ ['SPI', 'Spitfire'],
+ ['SS', 'Sunny Starscout'],
+ ['STA', 'Star Dancer'],
+ ['STL', 'Starlight Glimmer'],
+ ['SPT', 'Sprout'],
+ ['SUN', 'Sunburst'],
+ ['SUS', 'Sunset Shimmer'],
+ ['SWB', 'Sweetie Belle'],
+ ['TFA', 'TFH Arizona'],
+ ['TFO', 'TFH Oleander'],
+ ['TFP', 'TFH Paprika'],
+ ['TFS', 'TFH Shanty'],
+ ['TFT', 'TFH Tianhuo'],
+ ['TFV', 'TFH Velvet'],
+ ['TP', 'TFH Pom'],
+ ['TS', 'Tempest Shadow'],
+ ['TWI', 'Twilight Sparkle'],
+ ['TX', 'Trixie'],
+ ['VS', 'Vinyl Scratch'],
+ ['ZE', 'Zecora'],
+ ['ZS', 'Zipp Storm'],
+] as const;
+
+const normalizeCode = (value: string | undefined) => value?.trim().toUpperCase();
+
+const buildFlagDefinitions = (
+ entries: readonly (readonly [string, string])[],
+ kind: BoardFlagKind,
+ spritePath: string,
+ width: number,
+ height: number,
+ columns: number,
+): ReadonlyMap =>
+ new Map(
+ entries.map(([code, label], index) => [
+ code,
+ {
+ kind,
+ code,
+ label,
+ spritePath,
+ width,
+ height,
+ x: (index % columns) * width,
+ y: Math.floor(index / columns) * height,
+ },
+ ]),
+ );
+
+const BOARD_FLAGS_BY_KIND: Record> = {
+ pol: buildFlagDefinitions(POLITICAL_FLAG_ENTRIES, 'pol', POLITICAL_FLAG_SPRITE_PATH, POLITICAL_FLAG_WIDTH, POLITICAL_FLAG_HEIGHT, POLITICAL_FLAG_COLUMNS),
+ pony: buildFlagDefinitions(PONY_FLAG_ENTRIES, 'pony', PONY_FLAG_SPRITE_PATH, PONY_FLAG_WIDTH, PONY_FLAG_HEIGHT, PONY_FLAG_COLUMNS),
+};
+
+export const normalizeBoardFlagKind = (value: string | undefined): BoardFlagKind | undefined => {
+ const kind = value?.trim().toLowerCase();
+ if (kind === 'pol' || kind === 'political' || kind === 'meme' || kind === 'memeflag' || kind === 'memeflags') {
+ return 'pol';
+ }
+ if (kind === 'pony' || kind === 'mlp') {
+ return 'pony';
+ }
+ return undefined;
+};
+
+export const getBoardFlagDefinition = (kindValue: string | undefined, codeValue: string | undefined): BoardFlagDefinition | undefined => {
+ const kind = normalizeBoardFlagKind(kindValue);
+ const code = normalizeCode(codeValue);
+ if (!kind || !code) return undefined;
+ return BOARD_FLAGS_BY_KIND[kind].get(code);
+};
+
+export const getBoardFlagDefinitions = (kindValue: string | undefined): BoardFlagDefinition[] => {
+ const kind = normalizeBoardFlagKind(kindValue);
+ return kind ? [...BOARD_FLAGS_BY_KIND[kind].values()] : [];
+};
diff --git a/src/lib/comment-flag-selection.ts b/src/lib/comment-flag-selection.ts
new file mode 100644
index 00000000..e0891395
--- /dev/null
+++ b/src/lib/comment-flag-selection.ts
@@ -0,0 +1,108 @@
+import { getBoardFlagDefinition, getBoardFlagDefinitions, type BoardFlagKind } from './board-flags';
+import type { DirectoryCommunity } from './utils/directory-list-utils';
+
+const GEOGRAPHIC_LOCATION_FLAG_VALUE = 'country:auto';
+const NO_FLAG_VALUE = 'none';
+const FLAG_CHALLENGE_ANSWER_PREFIX = 'bitsocial-flags:5chan:';
+
+export interface CommentFlagSelectOption {
+ value: string;
+ label: string;
+}
+
+export interface CommentFlagRequest {
+ type: 'country' | BoardFlagKind;
+ code: string;
+ text: string;
+}
+
+interface CommentFlagChallengeRequest {
+ challengeAnswers: string[];
+}
+
+export interface CommentFlagPublishOptions {
+ challengeRequest: CommentFlagChallengeRequest | undefined;
+ flairs: CommentFlagRequest[] | undefined;
+}
+
+const GEOGRAPHIC_LOCATION_OPTION: CommentFlagSelectOption = {
+ value: GEOGRAPHIC_LOCATION_FLAG_VALUE,
+ label: 'Geographic Location',
+};
+
+const NO_FLAG_OPTION: CommentFlagSelectOption = {
+ value: NO_FLAG_VALUE,
+ label: 'None',
+};
+
+const getDirectoryCode = (directory: Pick | undefined): string | undefined => {
+ const directoryCode = directory?.directoryCode?.trim().toLowerCase();
+ return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
+};
+
+const getBoardFlagOptions = (kind: BoardFlagKind): CommentFlagSelectOption[] =>
+ getBoardFlagDefinitions(kind).map((flag) => ({
+ value: `${flag.kind}:${flag.code}`,
+ label: flag.label,
+ }));
+
+export const getCommentFlagOptionsForDirectory = (directory: Pick | undefined): CommentFlagSelectOption[] => {
+ if (directory?.features?.hasFlags !== true) {
+ return [];
+ }
+
+ const directoryCode = getDirectoryCode(directory);
+ if (directoryCode === 'mlp') {
+ return [NO_FLAG_OPTION, ...getBoardFlagOptions('pony')];
+ }
+
+ if (directoryCode === 'pol') {
+ return [GEOGRAPHIC_LOCATION_OPTION, ...getBoardFlagOptions('pol')];
+ }
+
+ return [GEOGRAPHIC_LOCATION_OPTION];
+};
+
+export const getCommentFlagRequestFromSelection = (value: string | undefined): CommentFlagRequest | undefined => {
+ if (!value || value === NO_FLAG_VALUE) {
+ return undefined;
+ }
+
+ if (value === GEOGRAPHIC_LOCATION_FLAG_VALUE) {
+ return {
+ type: 'country',
+ code: 'auto',
+ text: 'flag:country:auto',
+ };
+ }
+
+ const match = value.match(/^(pol|pony):([A-Z0-9]+)$/);
+ if (!match) {
+ return undefined;
+ }
+
+ const [, kind, code] = match;
+ const flag = getBoardFlagDefinition(kind, code);
+ return flag
+ ? {
+ type: flag.kind,
+ code: flag.code,
+ text: `flag:${flag.kind}:${flag.code}`,
+ }
+ : undefined;
+};
+
+export const getCommentFlagPublishOptionsFromSelection = (value: string | undefined): CommentFlagPublishOptions => {
+ const flag = getCommentFlagRequestFromSelection(value);
+ return flag
+ ? {
+ challengeRequest: {
+ challengeAnswers: [`${FLAG_CHALLENGE_ANSWER_PREFIX}${flag.text}`],
+ },
+ flairs: [flag],
+ }
+ : {
+ challengeRequest: undefined,
+ flairs: undefined,
+ };
+};
diff --git a/src/lib/comment-flags.ts b/src/lib/comment-flags.ts
new file mode 100644
index 00000000..288a29b4
--- /dev/null
+++ b/src/lib/comment-flags.ts
@@ -0,0 +1,221 @@
+import { COUNTRY_FLAG_HEIGHT, COUNTRY_FLAG_WIDTH, getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from './country-flags';
+import { getBoardFlagDefinition, normalizeBoardFlagKind, type BoardFlagKind } from './board-flags';
+
+const COUNTRY_FLAG_SPRITE_PATH = 'assets/icons/flags-1.png';
+const FLAG_TEXT_PATTERN = /^flag:([a-z-]+):([a-z0-9-]+)$/i;
+const SHORT_FLAG_TEXT_PATTERN = /^(country|geo|pol|political|meme|memeflag|memeflags|pony|mlp):([a-z0-9-]+)$/i;
+const COUNTRY_EMOJI_TEXT_PATTERN = /^([a-z]{2}|catalonia|eu|fam|xe|xs|xw|xk|xx)-emoji$/i;
+
+type FlagSource =
+ | { type: 'country'; code: string }
+ | {
+ type: BoardFlagKind;
+ code: string;
+ };
+
+interface AuthorFlairLike {
+ text?: unknown;
+ code?: unknown;
+ country?: unknown;
+ type?: unknown;
+ kind?: unknown;
+ expiresAt?: unknown;
+}
+
+interface AuthorLike {
+ flairs?: unknown;
+ community?: {
+ flairs?: unknown;
+ };
+ subplebbit?: {
+ flairs?: unknown;
+ };
+}
+
+interface CommentLike {
+ '5chan'?: unknown;
+ author?: unknown;
+ flairs?: unknown;
+ raw?: {
+ comment?: unknown;
+ };
+}
+
+export interface AuthorFlagViewModel {
+ key: string;
+ type: 'country' | BoardFlagKind;
+ code: string;
+ label: string;
+ spritePath: string;
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+}
+
+const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const normalizeFlairsContainer = (flairs: unknown): unknown => {
+ if (Array.isArray(flairs)) return flairs;
+ return isRecord(flairs) && Array.isArray(flairs.author) ? flairs.author : undefined;
+};
+
+export const getAuthorFlagFlairs = (author: unknown): unknown => {
+ if (!isRecord(author)) return undefined;
+
+ const authorLike = author as AuthorLike;
+ return normalizeFlairsContainer(authorLike.community?.flairs) ?? normalizeFlairsContainer(authorLike.subplebbit?.flairs) ?? normalizeFlairsContainer(authorLike.flairs);
+};
+
+const getFiveChanFlagFlair = (comment: unknown): AuthorFlairLike | undefined => {
+ if (!isRecord(comment)) return undefined;
+
+ const fiveChan = (comment as CommentLike)['5chan'];
+ if (!isRecord(fiveChan) || !isRecord(fiveChan.flag)) return undefined;
+
+ const flag = fiveChan.flag;
+ const text = toString(flag.text);
+ const type = toString(flag.type);
+ const code = toString(flag.code) ?? toString(fiveChan.country);
+ const country = toString(fiveChan.country);
+
+ if (!text && (!type || !code)) {
+ return undefined;
+ }
+
+ return {
+ text,
+ type,
+ code,
+ country,
+ };
+};
+
+export const getCommentFlagFlairs = (comment: unknown, author?: unknown): unknown => {
+ if (!isRecord(comment)) return getAuthorFlagFlairs(author);
+
+ const commentLike = comment as CommentLike;
+ const fiveChanFlagFlair = getFiveChanFlagFlair(comment) ?? getFiveChanFlagFlair(commentLike.raw?.comment);
+ if (fiveChanFlagFlair) {
+ return [fiveChanFlagFlair];
+ }
+
+ return getAuthorFlagFlairs(author ?? commentLike.author) ?? normalizeFlairsContainer(commentLike.flairs);
+};
+
+const isFreshFlair = (flair: AuthorFlairLike, nowSeconds: number) =>
+ typeof flair.expiresAt !== 'number' || !Number.isFinite(flair.expiresAt) || flair.expiresAt > nowSeconds;
+
+const parseTextFlagSource = (text: string | undefined): FlagSource | undefined => {
+ if (!text) return undefined;
+
+ const flagMatch = text.match(FLAG_TEXT_PATTERN);
+ if (flagMatch) {
+ const [, kindValue, code] = flagMatch;
+ const normalizedKindValue = kindValue.toLowerCase();
+ if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') {
+ return { type: 'country', code };
+ }
+ const kind = normalizeBoardFlagKind(normalizedKindValue);
+ return kind ? { type: kind, code } : undefined;
+ }
+
+ const shortMatch = text.match(SHORT_FLAG_TEXT_PATTERN);
+ if (shortMatch) {
+ const [, kindValue, code] = shortMatch;
+ const normalizedKindValue = kindValue.toLowerCase();
+ if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') {
+ return { type: 'country', code };
+ }
+ const kind = normalizeBoardFlagKind(normalizedKindValue);
+ return kind ? { type: kind, code } : undefined;
+ }
+
+ const countryEmojiMatch = text.match(COUNTRY_EMOJI_TEXT_PATTERN);
+ return countryEmojiMatch ? { type: 'country', code: countryEmojiMatch[1] } : undefined;
+};
+
+const parseStructuredFlagSource = (flair: AuthorFlairLike): FlagSource | undefined => {
+ const typeValue = toString(flair.type) ?? toString(flair.kind);
+ if (!typeValue) return undefined;
+
+ const normalizedTypeValue = typeValue.toLowerCase();
+ if (normalizedTypeValue === 'country' || normalizedTypeValue === 'geo') {
+ const code = toString(flair.country) ?? toString(flair.code);
+ return code ? { type: 'country', code } : undefined;
+ }
+
+ const kind = normalizeBoardFlagKind(normalizedTypeValue);
+ const code = toString(flair.code);
+ return kind && code ? { type: kind, code } : undefined;
+};
+
+const toCountryFlagViewModel = (codeValue: string): AuthorFlagViewModel | undefined => {
+ const code = normalizeCountryCode(codeValue);
+ const position = getCountryFlagPosition(code);
+ if (!code || !position) return undefined;
+
+ return {
+ key: `country:${code}`,
+ type: 'country',
+ code,
+ label: getCountryLabel(code) ?? code.toUpperCase(),
+ spritePath: COUNTRY_FLAG_SPRITE_PATH,
+ width: COUNTRY_FLAG_WIDTH,
+ height: COUNTRY_FLAG_HEIGHT,
+ x: position.x,
+ y: position.y,
+ };
+};
+
+const toBoardFlagViewModel = (type: BoardFlagKind, code: string): AuthorFlagViewModel | undefined => {
+ const flag = getBoardFlagDefinition(type, code);
+ return flag
+ ? {
+ key: `${flag.kind}:${flag.code}`,
+ type: flag.kind,
+ code: flag.code,
+ label: flag.label,
+ spritePath: flag.spritePath,
+ width: flag.width,
+ height: flag.height,
+ x: flag.x,
+ y: flag.y,
+ }
+ : undefined;
+};
+
+const toFlagViewModel = (source: FlagSource | undefined): AuthorFlagViewModel | undefined => {
+ if (!source) return undefined;
+ return source.type === 'country' ? toCountryFlagViewModel(source.code) : toBoardFlagViewModel(source.type, source.code);
+};
+
+export const getAuthorFlagViewModels = (flairs: unknown, nowSeconds = Date.now() / 1000): AuthorFlagViewModel[] => {
+ if (!Array.isArray(flairs)) return [];
+
+ const seenKeys = new Set();
+ const flags: AuthorFlagViewModel[] = [];
+
+ for (const flair of flairs) {
+ if (typeof flair !== 'object' || flair === null) {
+ continue;
+ }
+
+ const flairRecord = flair as AuthorFlairLike;
+ if (!isFreshFlair(flairRecord, nowSeconds)) {
+ continue;
+ }
+
+ const flag = toFlagViewModel(parseStructuredFlagSource(flairRecord) ?? parseTextFlagSource(toString(flairRecord.text)));
+ if (!flag || seenKeys.has(flag.key)) {
+ continue;
+ }
+
+ seenKeys.add(flag.key);
+ flags.push(flag);
+ }
+
+ return flags;
+};
diff --git a/src/stores/__tests__/publish-stores.test.ts b/src/stores/__tests__/publish-stores.test.ts
index 268cb7d9..2456dfde 100644
--- a/src/stores/__tests__/publish-stores.test.ts
+++ b/src/stores/__tests__/publish-stores.test.ts
@@ -43,6 +43,10 @@ describe('publish stores', () => {
spoiler: true,
communityAddress: 'music-posting.eth',
title: 'Hello',
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
+ },
+ flairs: [{ text: 'flag:country:auto', type: 'country', code: 'auto' }],
};
usePublishPostStore.getState().setPublishPostStore(comment);
@@ -53,6 +57,10 @@ describe('publish stores', () => {
expect(state.publishCommentOptions.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' });
expect(state.publishCommentOptions.communityAddress).toBe('music-posting.eth');
expect(state.publishCommentOptions.title).toBe('Hello');
+ expect(state.publishCommentOptions.challengeRequest).toEqual({
+ challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
+ });
+ expect(state.publishCommentOptions.flairs).toEqual([{ text: 'flag:country:auto', type: 'country', code: 'auto' }]);
state.publishCommentOptions.onChallengeVerification?.({} as never, comment);
expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({}, comment);
@@ -88,6 +96,10 @@ describe('publish stores', () => {
parentCid: 'parent-1',
spoiler: false,
communityAddress: 'music-posting.eth',
+ challengeRequest: {
+ challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
+ },
+ flairs: [{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }],
};
usePublishReplyStore.getState().setPublishReplyStore(comment);
@@ -98,6 +110,10 @@ describe('publish stores', () => {
expect(state.publishCommentOptions['parent-1']?.communityAddress).toBe('music-posting.eth');
expect(state.publishCommentOptions['parent-1']?.parentCid).toBe('parent-1');
expect(state.publishCommentOptions['parent-1']?.postCid).toBe('parent-1');
+ expect(state.publishCommentOptions['parent-1']?.challengeRequest).toEqual({
+ challengeAnswers: ['bitsocial-flags:5chan:flag:pol:AC'],
+ });
+ expect(state.publishCommentOptions['parent-1']?.flairs).toEqual([{ text: 'flag:pol:AC', type: 'pol', code: 'AC' }]);
state.publishCommentOptions['parent-1']?.onChallengeVerification?.({ token: 'challenge' } as never, comment);
expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({ token: 'challenge' }, comment);
diff --git a/src/stores/use-publish-post-store.ts b/src/stores/use-publish-post-store.ts
index 8f732de3..da88e5fd 100644
--- a/src/stores/use-publish-post-store.ts
+++ b/src/stores/use-publish-post-store.ts
@@ -11,7 +11,9 @@ type SubmitState = {
title: string | undefined;
content: string | undefined;
link: string | undefined;
+ flairs: Comment['flairs'] | undefined;
spoiler: boolean | undefined;
+ challengeRequest: Record | undefined;
publishCommentOptions: PublishCommentOptions;
setPublishPostStore: (data: Partial) => void;
resetPublishPostStore: () => void;
@@ -24,11 +26,13 @@ const usePublishPostStore = create((set) => ({
title: undefined,
content: undefined,
link: undefined,
+ flairs: undefined,
spoiler: undefined,
+ challengeRequest: undefined,
publishCommentOptions: {},
setPublishPostStore: (comment: Comment) =>
set(() => {
- const { author, content, spoiler, title } = comment;
+ const { author, challengeRequest, content, flairs, spoiler, title } = comment;
const link = comment.link ? normalizePublishURL(comment.link) : undefined;
const communityAddress = getCommentCommunityAddress(comment);
@@ -44,7 +48,9 @@ const usePublishPostStore = create((set) => ({
title,
content,
link,
+ flairs,
spoiler,
+ ...(challengeRequest ? { challengeRequest } : {}),
onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
@@ -65,7 +71,9 @@ const usePublishPostStore = create((set) => ({
title,
content,
link,
+ flairs,
spoiler,
+ challengeRequest,
publishCommentOptions,
};
}),
@@ -77,7 +85,9 @@ const usePublishPostStore = create((set) => ({
title: undefined,
content: undefined,
link: undefined,
+ flairs: undefined,
spoiler: undefined,
+ challengeRequest: undefined,
publishCommentOptions: {},
}),
}));
diff --git a/src/stores/use-publish-reply-store.ts b/src/stores/use-publish-reply-store.ts
index b85e4df8..9e273d21 100644
--- a/src/stores/use-publish-reply-store.ts
+++ b/src/stores/use-publish-reply-store.ts
@@ -9,7 +9,9 @@ type ReplyState = {
displayName: { [parentCid: string]: string | undefined };
content: { [parentCid: string]: string | undefined };
link: { [parentCid: string]: string | undefined };
+ flairs: { [parentCid: string]: Comment['flairs'] | undefined };
spoiler: { [parentCid: string]: boolean | undefined };
+ challengeRequest: { [parentCid: string]: Record | undefined };
publishCommentOptions: { [parentCid: string]: PublishCommentOptions | undefined };
setPublishReplyStore: (comment: Comment) => void;
resetPublishReplyStore: (parentCid: string) => void;
@@ -20,12 +22,14 @@ const usePublishReplyStore = create((set) => ({
displayName: {},
content: {},
link: {},
+ flairs: {},
spoiler: {},
+ challengeRequest: {},
publishCommentOptions: {},
setPublishReplyStore: (comment: Comment) =>
set((state) => {
- const { parentCid, author, content, spoiler } = comment;
+ const { parentCid, author, challengeRequest, content, flairs, spoiler } = comment;
const link = comment.link ? normalizePublishURL(comment.link) : undefined;
const communityAddress = getCommentCommunityAddress(comment);
@@ -42,7 +46,9 @@ const usePublishReplyStore = create((set) => ({
postCid: comment?.postCid || parentCid,
content,
link,
+ flairs,
spoiler,
+ ...(challengeRequest ? { challengeRequest } : {}),
onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
@@ -61,7 +67,9 @@ const usePublishReplyStore = create((set) => ({
displayName: { ...state.displayName, [parentCid]: displayName },
content: { ...state.content, [parentCid]: content },
link: { ...state.link, [parentCid]: link },
+ flairs: { ...state.flairs, [parentCid]: flairs },
spoiler: { ...state.spoiler, [parentCid]: spoiler },
+ challengeRequest: { ...state.challengeRequest, [parentCid]: challengeRequest },
publishCommentOptions: { ...state.publishCommentOptions, [parentCid]: publishCommentOptions },
};
}),
@@ -72,7 +80,9 @@ const usePublishReplyStore = create((set) => ({
displayName: { ...state.displayName, [parentCid]: undefined },
content: { ...state.content, [parentCid]: undefined },
link: { ...state.link, [parentCid]: undefined },
+ flairs: { ...state.flairs, [parentCid]: undefined },
spoiler: { ...state.spoiler, [parentCid]: undefined },
+ challengeRequest: { ...state.challengeRequest, [parentCid]: undefined },
publishCommentOptions: { ...state.publishCommentOptions, [parentCid]: undefined },
})),
}));
diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css
index 5d5e2879..0684acdf 100644
--- a/src/views/post/post.module.css
+++ b/src/views/post/post.module.css
@@ -129,6 +129,21 @@
font-weight: var(--post-name-font-weight);
}
+.authorFlags {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-right: 3px;
+ vertical-align: -1px;
+}
+
+.authorFlag {
+ display: inline-block;
+ flex: 0 0 auto;
+ background-repeat: no-repeat;
+ image-rendering: pixelated;
+}
+
.postDesktop .postNumLink a, .postDesktop .postNumLink span, .replyDesktop .postNumLink a, .replyDesktop .postNumLink span {
color: unset;
text-decoration: unset;
diff --git a/yarn.lock b/yarn.lock
index c789194a..5ed2f0c8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -25,7 +25,7 @@ __metadata:
"@electron-forge/maker-zip": "npm:7.8.0"
"@electron/rebuild": "npm:3.7.2"
"@floating-ui/react": "npm:0.26.1"
- "@pkcprotocol/pkc-js": "npm:0.0.35"
+ "@pkcprotocol/pkc-js": "npm:0.0.37"
"@react-spring/web": "npm:10.0.3"
"@reforged/maker-appimage": "npm:5.1.1"
"@types/lodash": "npm:4.17.24"
@@ -3958,15 +3958,15 @@ __metadata:
languageName: node
linkType: hard
-"@libp2p/gossipsub@npm:15.0.21":
- version: 15.0.21
- resolution: "@libp2p/gossipsub@npm:15.0.21"
+"@libp2p/gossipsub@npm:15.0.23":
+ version: 15.0.23
+ resolution: "@libp2p/gossipsub@npm:15.0.23"
dependencies:
- "@libp2p/crypto": "npm:^5.1.17"
+ "@libp2p/crypto": "npm:^5.1.18"
"@libp2p/interface": "npm:^3.2.2"
- "@libp2p/interface-internal": "npm:^3.1.3"
- "@libp2p/peer-id": "npm:^6.0.8"
- "@libp2p/utils": "npm:^7.1.0"
+ "@libp2p/interface-internal": "npm:^3.1.5"
+ "@libp2p/peer-id": "npm:^6.0.9"
+ "@libp2p/utils": "npm:^7.2.1"
"@multiformats/multiaddr": "npm:^13.0.1"
denque: "npm:^2.1.0"
it-length-prefixed: "npm:^10.0.1"
@@ -3976,7 +3976,7 @@ __metadata:
protons-runtime: "npm:^6.0.1"
uint8arraylist: "npm:^2.4.8"
uint8arrays: "npm:^5.0.1"
- checksum: 10c0/d7001866a16020dd0a9cf49df0a798c97102851d991da21528dda91b190bb5e069151d08e36549d5c9ccdce481fe876adadd448121fcafb267049bc9ba5264d2
+ checksum: 10c0/1bf9caeea0ae27813e6c22ce855d372d96bea2ac8599dca40722b727a40f6dc1625b07a26de82a62e1bfdc6de820fb13ba8c392d8a06aa93f7d6669bf192bbd3
languageName: node
linkType: hard
@@ -5829,9 +5829,9 @@ __metadata:
languageName: node
linkType: hard
-"@pkcprotocol/pkc-js@npm:0.0.35":
- version: 0.0.35
- resolution: "@pkcprotocol/pkc-js@npm:0.0.35"
+"@pkcprotocol/pkc-js@npm:0.0.37":
+ version: 0.0.37
+ resolution: "@pkcprotocol/pkc-js@npm:0.0.37"
dependencies:
"@enhances/with-resolvers": "npm:0.0.5"
"@helia/block-brokers": "npm:5.2.4"
@@ -5880,7 +5880,7 @@ __metadata:
typestub-ipfs-only-hash: "npm:4.0.0"
uuid: "npm:13.0.0"
zod: "npm:4.3.6"
- checksum: 10c0/b218f19f8f01c422f68069f9f49e08dd3f57cfa082cb1213cc30c624e2ee6e7608480ae34152e23ba5d6b78ba21b0841f29e623632f7b97561cb1314a748d265
+ checksum: 10c0/4a76a0a64685414ce3eee004189b96beb6ddfeee654eb191c104c8a38654b0b15de591966f39de67f5961ba07fe2cf0bedc744c280382b93e52ece2887618c58
languageName: node
linkType: hard
|