feat(author badges): add 5chan developer badges

This commit is contained in:
Tommaso Casaburi
2026-05-13 17:29:33 +07:00
parent c40353d2e6
commit 9f6c073264
8 changed files with 214 additions and 20 deletions
@@ -12,6 +12,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
type TestComment = {
author?: {
address?: string;
displayName?: string;
shortAddress?: string;
};
cid?: string;
@@ -479,6 +480,24 @@ describe('post community address compatibility', () => {
expect(container.textContent).toContain('reply-1');
});
it('renders known developer badges and keeps anonymous as the default name on desktop and mobile', async () => {
const post = {
...makeLegacyThread(),
author: { address: 'plebeius.bso', shortAddress: 'plebeius.bso' },
};
const roles = { 'plebeius.bso': { role: 'owner' } };
await renderWithRoute(createElement(PostDesktop, { post, roles } as any));
expect(container.textContent).toContain('Anonymous');
expect(container.textContent).toContain('## 5chan Dev');
expect(container.querySelector('.capcodeAdminIcon')).toBeTruthy();
await renderWithRoute(createElement(PostMobile, { post, roles } as any));
expect(container.textContent).toContain('Anonymous');
expect(container.textContent).toContain('## 5chan Dev');
expect(container.querySelector('.capcodeAdminIcon')).toBeTruthy();
});
it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => {
testState.hasMoreReplies = true;
@@ -12,6 +12,7 @@ type TestComment = {
author?: {
address?: string;
displayName?: string;
shortAddress?: string;
};
cid: string;
content?: string;
@@ -379,6 +380,44 @@ describe('CatalogRow', () => {
expect(document.body.textContent).toContain('ago:200');
});
it('uses developer badges and keeps anonymous as the default name in hover previews', async () => {
testState.mediaInfoByLink['https://example.com/dev.png'] = { type: 'image', url: 'https://example.com/dev.png' };
testState.replies = [
{
author: { address: 'rinse12.bso', shortAddress: 'rinse12.bso' },
cid: 'reply-dev',
timestamp: 200,
},
];
testState.roleByAddress = {
'plebeius.bso': { commentAuthorRole: 'owner', isCommentAuthorMod: true },
};
const post: TestComment = {
author: { address: 'plebeius.bso', shortAddress: 'plebeius.bso' },
cid: 'post-dev',
content: 'Developer post',
link: 'https://example.com/dev.png',
replyCount: 1,
communityAddress: 'music-posting.eth',
timestamp: 100,
title: 'Dev thread',
};
await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog');
vi.useFakeTimers();
const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-dev"] > div');
await act(async () => {
previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
vi.advanceTimersByTime(260);
await Promise.resolve();
});
expect(document.body.textContent).toContain('Dev thread by Anonymous ## 5chan Dev');
expect(document.body.textContent).toContain('last_reply_by Anonymous ## 5chan Dev');
});
it('uses alias-aware board features when deciding whether reply links are media', async () => {
testState.directories = [{ address: 'music-posting.bso', features: { requirePostLinkIsMedia: true }, title: '/mu/ - Music' }];
testState.linkCount = 3;
+13 -6
View File
@@ -24,6 +24,7 @@ import styles from './catalog-row.module.css';
import capitalize from 'lodash/capitalize';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
interface CatalogPostMediaProps {
cid: string;
@@ -194,14 +195,16 @@ const CatalogPost = memo(
const { replies } = useReplies({ comment: showPortal ? resolvedPost : undefined, flat: true });
const lastReply = replies?.length > 0 ? replies[replies.length - 1] : null;
const { isCommentAuthorMod: isCatalogPostAuthorMod, commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({
const { commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({
commentAuthorAddress: author?.address,
communityAddress: communityAddress ?? '',
});
const { isCommentAuthorMod: isLastReplyAuthorMod, commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({
const { commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({
commentAuthorAddress: lastReply?.author?.address,
communityAddress: communityAddress ?? '',
});
const catalogPostAuthorBadge = getAuthorBadge({ address: author?.address, role: catalogPostAuthorRole });
const lastReplyAuthorBadge = getAuthorBadge({ address: lastReply?.author?.address, role: lastReplyAuthorRole });
const postContent = (
<div className={`${styles.teaser} ${hidden && styles.hidden}`}>
@@ -300,18 +303,22 @@ const CatalogPost = memo(
) : (
t('posted_by')
)}{' '}
<span className={`${styles.postAuthor} ${isCatalogPostAuthorMod && styles.capcode}`}>
<span className={`${styles.postAuthor} ${catalogPostAuthorBadge ? styles.capcode : ''}`}>
{author?.displayName || capitalize(t('anonymous'))}
{isCatalogPostAuthorMod && <span className='capitalize'>{` ## Board ${catalogPostAuthorRole}`}</span>}
{catalogPostAuthorBadge && (
<span className={catalogPostAuthorBadge.capitalizeLabel ? 'capitalize' : undefined}>{` ## ${catalogPostAuthorBadge.label}`}</span>
)}
</span>
{(isInAllView || isInSubscriptionsView) && communityAddress && ` to p/${getShortAddress(communityAddress)}`}
<span className={styles.postAgo}> {getFormattedTimeAgo(timestamp)}</span>
{replyCount > 0 && (
<div className={styles.postLast}>
{t('last_reply_by')}{' '}
<span className={`${styles.postAuthor} ${isLastReplyAuthorMod && styles.capcode}`}>
<span className={`${styles.postAuthor} ${lastReplyAuthorBadge ? styles.capcode : ''}`}>
{lastReply?.author?.displayName || capitalize(t('anonymous'))}
{isLastReplyAuthorMod && ` ## Board ${lastReplyAuthorRole}`}
{lastReplyAuthorBadge && (
<span className={lastReplyAuthorBadge.capitalizeLabel ? 'capitalize' : undefined}>{` ## ${lastReplyAuthorBadge.label}`}</span>
)}
</span>
<span className={styles.postAgo}> {getFormattedTimeAgo(lastReply?.timestamp)}</span>
</div>
+10 -7
View File
@@ -68,6 +68,7 @@ import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
const { addChallenge } = useChallengesStore.getState();
@@ -233,7 +234,7 @@ const PostInfo = ({
const title = post?.title?.trim();
const { address, shortAddress } = author || {};
const displayName = author?.displayName?.trim();
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role });
const hasFailedState = state === 'failed';
const isReply = parentCid;
const { showOmittedReplies } = useShowOmittedReplies();
@@ -345,7 +346,9 @@ const PostInfo = ({
</Tooltip>
))}
<span className={styles.nameBlock}>
<span className={`${styles.name} ${authorRole && !(deleted || removed || purged) && (authorRole === 'mod' ? styles.capcodeMod : styles.capcodeAdmin)}`}>
<span
className={`${styles.name} ${authorBadge && !(deleted || removed || purged) ? (authorBadge.icon === 'mod' ? styles.capcodeMod : styles.capcodeAdmin) : ''}`}
>
{deleted ? (
capitalize(t('deleted'))
) : removed ? (
@@ -363,13 +366,13 @@ const PostInfo = ({
) : (
capitalize(t('anonymous'))
)}
{!(deleted || removed || purged) && authorRole && (
<span className='capitalize'>
{!(deleted || removed || purged) && authorBadge && (
<span className={authorBadge.capitalizeLabel ? 'capitalize' : undefined}>
{' '}
## Board {authorRole}{' '}
## {authorBadge.label}{' '}
<span
className={`${styles.capcodeIcon} ${authorRole === 'mod' ? styles.capcodeModIcon : styles.capcodeAdminIcon}`}
title={authorRole === 'mod' ? t('moderator_of_this_board') : t('administrator_of_this_board')}
className={`${styles.capcodeIcon} ${authorBadge.icon === 'mod' ? styles.capcodeModIcon : styles.capcodeAdminIcon}`}
title={authorBadge.title === '5chan Dev' ? authorBadge.title : t(authorBadge.title)}
/>
</span>
)}{' '}
+10 -7
View File
@@ -57,6 +57,7 @@ import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
const { addChallenge } = useChallengesStore.getState();
@@ -91,7 +92,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
const title = post?.title?.trim();
const { address, shortAddress } = author || {};
const displayName = author?.displayName?.trim();
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role });
const params = useParams();
const location = useLocation();
@@ -269,7 +270,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
<PostMenuMobile postMenu={postMenuProps} editMenuPost={resolvedPost} />
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
<span className={styles.nameBlock}>
<span className={`${styles.name} ${authorRole && !(deleted || removed || purged) && (authorRole === 'mod' ? styles.capcodeMod : styles.capcodeAdmin)}`}>
<span
className={`${styles.name} ${authorBadge && !(deleted || removed || purged) ? (authorBadge.icon === 'mod' ? styles.capcodeMod : styles.capcodeAdmin) : ''}`}
>
{removed ? (
capitalize(t('removed'))
) : deleted ? (
@@ -287,14 +290,14 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
) : (
capitalize(t('anonymous'))
)}{' '}
{!(deleted || removed || purged) && authorRole && (
<span className='capitalize'>
{!(deleted || removed || purged) && authorBadge && (
<span className={authorBadge.capitalizeLabel ? 'capitalize' : undefined}>
{' '}
## Board {authorRole}{' '}
## {authorBadge.label}{' '}
<span className={styles.capcodeIconMobileWrapper}>
<span
className={`${styles.capcodeIconMobile} ${authorRole === 'mod' ? styles.capcodeModIcon : styles.capcodeAdminIcon}`}
title={authorRole === 'mod' ? t('moderator_of_this_board') : t('administrator_of_this_board')}
className={`${styles.capcodeIconMobile} ${authorBadge.icon === 'mod' ? styles.capcodeModIcon : styles.capcodeAdminIcon}`}
title={authorBadge.title === '5chan Dev' ? authorBadge.title : t(authorBadge.title)}
/>
</span>
&nbsp;
+17
View File
@@ -0,0 +1,17 @@
[
{
"address": "estebanabaroa.bso",
"name": "Esteban Abaroa",
"githubProfileUrl": "https://github.com/estebanabaroa"
},
{
"address": "rinse12.bso",
"name": "Rinse",
"githubProfileUrl": "https://github.com/rinse12"
},
{
"address": "plebeius.bso",
"name": "Tommaso Casaburi",
"githubProfileUrl": "https://github.com/tomcasaburi"
}
]
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { KNOWN_5CHAN_DEVELOPER_ENTRIES, getAuthorBadge, isKnown5chanDeveloper } from '../author-display-utils';
describe('author display utils', () => {
it('recognizes the hardcoded 5chan developer addresses', () => {
expect(isKnown5chanDeveloper('estebanabaroa.bso')).toBe(true);
expect(isKnown5chanDeveloper('rinse12.bso')).toBe(true);
expect(isKnown5chanDeveloper('plebeius.bso')).toBe(true);
expect(isKnown5chanDeveloper('someone-else.bso')).toBe(false);
});
it('keeps optional developer profile metadata next to the hardcoded address', () => {
expect(KNOWN_5CHAN_DEVELOPER_ENTRIES).toContainEqual({
address: 'rinse12.bso',
githubProfileUrl: 'https://github.com/rinse12',
name: 'Rinse',
});
expect(KNOWN_5CHAN_DEVELOPER_ENTRIES).toContainEqual({
address: 'plebeius.bso',
githubProfileUrl: 'https://github.com/tomcasaburi',
name: 'Tommaso Casaburi',
});
});
it('labels known developers with the admin icon style', () => {
expect(getAuthorBadge({ address: 'rinse12.bso' })).toEqual({
icon: 'admin',
label: '5chan Dev',
title: '5chan Dev',
});
});
it('keeps the developer label even when the account has a board role', () => {
expect(getAuthorBadge({ address: 'plebeius.bso', role: 'owner' })).toEqual({
icon: 'admin',
label: '5chan Dev',
title: '5chan Dev',
});
});
it('keeps board role labels for non-developers', () => {
expect(getAuthorBadge({ address: 'other.bso', role: 'moderator' })).toEqual({
capitalizeLabel: true,
icon: 'mod',
label: 'Board mod',
title: 'moderator_of_this_board',
});
expect(getAuthorBadge({ address: 'other.bso', role: 'owner' })).toEqual({
capitalizeLabel: true,
icon: 'admin',
label: 'Board owner',
title: 'administrator_of_this_board',
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import known5chanDeveloperEntries from '../../data/known-5chan-developers.json';
interface Known5chanDeveloperEntry {
address: string;
githubProfileUrl?: string;
name?: string;
}
export const KNOWN_5CHAN_DEVELOPER_ENTRIES = known5chanDeveloperEntries as readonly Known5chanDeveloperEntry[];
export const KNOWN_5CHAN_DEVELOPER_ADDRESSES = KNOWN_5CHAN_DEVELOPER_ENTRIES.map(({ address }) => address);
type AuthorBadgeIcon = 'admin' | 'mod';
interface AuthorBadge {
capitalizeLabel?: boolean;
icon: AuthorBadgeIcon;
label: string;
title: '5chan Dev' | 'administrator_of_this_board' | 'moderator_of_this_board';
}
const normalizeBoardRole = (role?: string): string | undefined => {
const normalizedRole = role?.trim();
if (!normalizedRole) return undefined;
return normalizedRole.toLowerCase() === 'moderator' ? 'mod' : normalizedRole;
};
export const isKnown5chanDeveloper = (address?: string): boolean =>
typeof address === 'string' && KNOWN_5CHAN_DEVELOPER_ENTRIES.some((developer) => developer.address === address);
export const getAuthorBadge = ({ address, role }: { address?: string; role?: string }): AuthorBadge | undefined => {
const boardRole = normalizeBoardRole(role);
const isDeveloper = isKnown5chanDeveloper(address);
if (isDeveloper) {
return {
icon: 'admin',
label: '5chan Dev',
title: '5chan Dev',
};
}
if (!boardRole) return undefined;
const isMod = boardRole?.toLowerCase() === 'mod';
return {
capitalizeLabel: true,
icon: isMod ? 'mod' : 'admin',
label: `Board ${boardRole}`,
title: isMod ? 'moderator_of_this_board' : 'administrator_of_this_board',
};
};