mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(author badges): add 5chan developer badges
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}{' '}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user