feat(flash board): add SWF posting support (#1145)

This commit is contained in:
Tommaso Casaburi
2026-05-30 16:07:41 +07:00
committed by GitHub
parent 56894700c1
commit 9b3a95dd95
69 changed files with 1372 additions and 63 deletions
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
hostname: 'example.com',
isMobile: false,
unmuteExpandedVideoSound: false,
ruffleLoadMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
@@ -69,6 +70,8 @@ vi.mock('../../embed', () => ({
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
}));
vi.mock('@ruffle-rs/ruffle', () => ({}));
let container: HTMLDivElement;
let root: Root;
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
@@ -90,6 +93,16 @@ describe('CommentMedia', () => {
testState.hostname = 'example.com';
testState.isMobile = false;
testState.unmuteExpandedVideoSound = false;
testState.ruffleLoadMock = vi.fn();
const player = document.createElement('ruffle-player') as HTMLElement & {
ruffle: () => { load: (source: string | Record<string, unknown>) => void };
};
player.ruffle = () => ({ load: testState.ruffleLoadMock });
window.RufflePlayer = {
newest: () => ({
createPlayer: () => player,
}),
};
setShowThumbnailMock = vi.fn();
container = document.createElement('div');
@@ -100,6 +113,7 @@ describe('CommentMedia', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
delete window.RufflePlayer;
});
it('toggles image expansion on mobile and renders the media metadata', async () => {
@@ -370,4 +384,52 @@ describe('CommentMedia', () => {
expect(video).toBeTruthy();
expect(video?.muted).toBe(true);
});
it('renders collapsed SWF media as a compact placeholder and expands through Ruffle', async () => {
await renderMedia({
commentMediaInfo: {
type: 'swf',
url: 'https://cdn.example.com/movie.swf',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
const placeholder = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '[SWF]');
expect(placeholder).toBeTruthy();
expect(container.querySelector('object')).toBeNull();
expect(container.querySelector('embed')).toBeNull();
await act(async () => {
placeholder?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(setShowThumbnailMock).toHaveBeenCalledWith(false);
await renderMedia({
commentMediaInfo: {
type: 'swf',
url: 'https://cdn.example.com/movie.swf',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: false,
});
await act(async () => {
await Promise.resolve();
});
expect(container.querySelector('[data-testid="ruffle-player"]')).toBeTruthy();
expect(testState.ruffleLoadMock).toHaveBeenCalledWith(
expect.objectContaining({
allowNetworking: 'internal',
allowScriptAccess: false,
autoplay: 'off',
openUrlMode: 'confirm',
url: 'https://cdn.example.com/movie.swf',
}),
);
expect(container.querySelector('object')).toBeNull();
expect(container.querySelector('embed')).toBeNull();
});
});
@@ -147,11 +147,77 @@
cursor: pointer;
}
.swfPlaceholder {
display: inline-block;
width: 100%;
border: 0;
background: transparent;
color: var(--post-link-text-color);
font-family: inherit;
font-size: 9pt;
line-height: 1.2;
padding: 0;
text-align: center;
text-decoration: var(--post-link-text-decoration);
cursor: pointer;
}
.swfPlaceholder:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.content iframe {
border: none;
color-scheme: light;
}
.rufflePlayer {
display: inline-flex;
align-items: center;
justify-content: center;
width: min(640px, 100%);
height: 480px;
max-width: 100%;
border: var(--reply-desktop-border);
background: var(--media-thumbnail-background-color);
box-sizing: border-box;
line-height: 1.2;
}
.rufflePlayerElement {
display: block;
width: 100%;
height: 100%;
}
.rufflePlayerMount {
display: block;
width: 100%;
height: 100%;
}
.rufflePlayerMountHidden {
display: none;
}
.ruffleLoading,
.ruffleFallback {
padding: 4px;
color: var(--post-mobile-file-info-text-color);
font-size: 9pt;
}
.ruffleFallback a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.ruffleFallback a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.fileInfo {
padding-bottom: 5px;
text-align: center;
+10 -1
View File
@@ -7,6 +7,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsMobile from '../../hooks/use-is-mobile';
import styles from './comment-media.module.css';
import Embed, { canEmbed } from '../embed';
import RufflePlayer from './ruffle-player';
interface MediaProps {
commentMediaInfo?: CommentMediaInfo;
@@ -143,6 +144,12 @@ const Thumbnail = ({
) : null;
} else if (type === 'audio') {
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
} else if (type === 'swf') {
thumbnailComponent = (
<button type='button' className={styles.swfPlaceholder} onClick={() => setShowThumbnail(false)}>
[SWF]
</button>
);
}
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
@@ -219,6 +226,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
)
) : type === 'video' ? (
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
) : type === 'swf' && url ? (
<RufflePlayer url={url} />
) : type === 'webpage' ? (
disableToggle ? (
<img src={thumbnail} alt='' />
@@ -237,7 +246,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
{mediaDimensions && `, ${mediaDimensions}`})
</div>
)}
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio' || type === 'swf') && (
<div className={styles.closeButton}>
<button
type='button'
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './comment-media.module.css';
const RUFFLE_CONFIG = {
allowFullscreen: false,
allowNetworking: 'internal',
allowScriptAccess: false,
autoplay: 'off',
openUrlMode: 'confirm',
polyfills: false,
showSwfDownload: false,
} as const;
let ruffleLoadPromise: Promise<void> | undefined;
const getRufflePublicPath = () => new URL('ruffle/', document.baseURI).href;
const getRuffleConfig = () => ({
...window.RufflePlayer?.config,
...RUFFLE_CONFIG,
publicPath: getRufflePublicPath(),
});
const loadRuffle = async () => {
if (!ruffleLoadPromise) {
window.RufflePlayer = window.RufflePlayer || {};
window.RufflePlayer.config = getRuffleConfig();
ruffleLoadPromise = import('@ruffle-rs/ruffle')
.then(() => undefined)
.catch((error: unknown) => {
ruffleLoadPromise = undefined;
throw error;
});
}
return ruffleLoadPromise;
};
interface RufflePlayerProps {
url: string;
}
type RufflePlayerStatus = 'loading' | 'ready' | 'failed';
const RufflePlayer = ({ url }: RufflePlayerProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLSpanElement>(null);
const [status, setStatus] = useState<RufflePlayerStatus>('loading');
useEffect(() => {
let cancelled = false;
let player: RufflePlayerElement | undefined;
const mountNode = containerRef.current;
const mountPlayer = async () => {
setStatus('loading');
if (mountNode) {
mountNode.textContent = '';
}
try {
await loadRuffle();
if (cancelled || !mountNode) {
return;
}
const ruffle = window.RufflePlayer?.newest?.();
player = ruffle?.createPlayer?.();
if (!player) {
throw new Error('Ruffle player API unavailable');
}
player.className = styles.rufflePlayerElement;
player.setAttribute('data-testid', 'ruffle-player');
mountNode.appendChild(player);
const playerApi = player.ruffle?.();
if (!playerApi) {
throw new Error('Ruffle player instance unavailable');
}
await playerApi.load({
...getRuffleConfig(),
url,
});
if (!cancelled) {
setStatus('ready');
}
} catch (error) {
console.error('Error loading SWF with Ruffle:', error);
if (!cancelled) {
setStatus('failed');
}
}
};
void mountPlayer();
return () => {
cancelled = true;
player?.remove();
if (mountNode) {
mountNode.textContent = '';
}
};
}, [url]);
return (
<span className={styles.rufflePlayer}>
<span className={status === 'ready' ? styles.rufflePlayerMount : styles.rufflePlayerMountHidden} ref={containerRef} />
{status === 'failed' ? (
<span className={styles.ruffleFallback}>
{t('media_failed_to_load')}.{' '}
<a href={url} target='_blank' rel='noopener noreferrer'>
{t('media_failed_to_load_open_source')}
</a>
</span>
) : status === 'loading' ? (
<span className={styles.ruffleLoading}>{t('loading')} SWF</span>
) : null}
</span>
);
};
export default RufflePlayer;
@@ -0,0 +1,166 @@
.wrapper {
overflow-x: auto;
padding: 0 5px;
}
.flashListing {
min-width: 760px;
width: 80%;
max-width: 810px;
margin: 10px auto 0;
border-collapse: separate;
border-spacing: 1px;
table-layout: auto;
}
.flashListing td,
.flashListing th {
padding: 2px;
font-size: 12px;
font-weight: normal;
text-align: center;
}
.flashListing thead th {
background: #98e;
border: 1px solid #000;
font-weight: 700;
}
.flashListing thead th:nth-child(1) {
width: 6.5%;
}
.flashListing thead th:nth-child(2) {
width: 11%;
}
.flashListing thead th:nth-child(3) {
width: 20%;
}
.flashListing thead th:nth-child(4) {
width: 7%;
}
.flashListing thead th:nth-child(5) {
width: 5%;
}
.flashListing thead th:nth-child(6) {
width: 23%;
}
.flashListing thead th:nth-child(7) {
width: 16%;
}
.flashListing thead th:nth-child(8) {
width: 7%;
}
.flashListing thead th:nth-child(9) {
width: 4.5%;
}
.postblock {
padding: 5px !important;
text-align: center;
}
.row {
background: transparent;
}
.numberCell,
.embedCell,
.tagCell,
.dateCell,
.repliesCell,
.replyCell {
white-space: nowrap;
}
.fileCell,
.subjectCell {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subjectCell {
text-align: left !important;
}
.emptyCell {
padding: 8px 2px !important;
text-align: center;
}
.link {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.link:hover {
color: var(--post-link-text-color-hover);
}
@media (max-width: 640px) {
.wrapper {
padding: 0;
}
.flashListing {
width: calc(100% - 10px);
max-width: none;
margin: 10px auto 0 auto;
}
}
:global(body.yotsuba) .rowOdd td {
background: #ede2d4;
}
:global(body.yotsuba) .flashListing thead th {
background: #ea8;
}
:global(body.yotsuba-b) .rowOdd td {
background: #e0e5f6;
}
:global(body.futaba) .rowOdd td {
background: #ede2d4;
}
:global(body.futaba) .flashListing thead th {
background: #f0e0d6;
}
:global(body.burichan) .rowOdd td {
background: #e0e5f6;
}
:global(body.burichan) .flashListing thead th {
background: #c3c9e9;
}
:global(body.tomorrow) .rowOdd td {
background: rgba(255, 255, 255, 0.1);
}
:global(body.tomorrow) .flashListing thead th {
background: var(--post-form-field-title-background-color);
color: var(--post-form-field-title-color);
border: none;
}
:global(body.photon) .rowOdd td {
background: #888;
}
:global(body.photon) .flashListing thead th {
background: #ddd;
}
@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import capitalize from 'lodash/capitalize';
import { getFlashTagOptionFromComment } from '../../lib/flash-tags';
import { removeMarkdown } from '../../lib/utils/post-utils';
import { getFormattedDate } from '../../lib/utils/time-utils';
import { getPublishURLFilename } from '../../lib/utils/url-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import LoadingEllipsis from '../loading-ellipsis';
import styles from './flash-board-table.module.css';
type FlashBoardComment = Comment & {
postNumber?: number | string;
};
interface FlashBoardTableProps {
boardBasePath: string;
isLoading?: boolean;
posts: FlashBoardComment[];
}
const CELL_COUNT = 9;
const MAX_CELL_TEXT_LENGTH = 40;
const MAX_SUBJECT_LENGTH = 55;
const getThreadPath = (boardBasePath: string, comment: FlashBoardComment) => {
const threadCid = comment.postCid || comment.cid;
return threadCid ? `${boardBasePath.replace(/\/$/, '')}/thread/${threadCid}` : undefined;
};
const getPostNumber = (comment: FlashBoardComment) => comment.number || comment.postNumber || '?';
const getDisplayName = (comment: FlashBoardComment, anonymousLabel: string) => comment.author?.displayName?.trim() || anonymousLabel;
const getFileLabel = (link: string | undefined) => {
if (!link) {
return '';
}
return truncateWithEllipsisInMiddle(getPublishURLFilename(link) || link, MAX_CELL_TEXT_LENGTH);
};
const getSubjectLabel = (comment: FlashBoardComment) => {
const title = typeof comment.title === 'string' ? removeMarkdown(comment.title).trim() : '';
const content = typeof comment.content === 'string' ? removeMarkdown(comment.content).trim() : '';
return truncateWithEllipsisInMiddle(title || content, MAX_SUBJECT_LENGTH);
};
const getReplyCount = (comment: FlashBoardComment) => (typeof comment.replyCount === 'number' ? comment.replyCount : 0);
const FlashBoardTable = ({ boardBasePath, isLoading = false, posts }: FlashBoardTableProps) => {
const { t } = useTranslation();
const anonymousLabel = capitalize(t('anonymous'));
return (
<div className={styles.wrapper}>
<table id='flash-list' className={styles.flashListing}>
<thead>
<tr>
<th scope='col' className={styles.postblock}>
No.
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('name'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('file'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('embed'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('tag'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('subject'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('date'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('replies'))}
</th>
<th scope='col' className={styles.postblock} aria-label={capitalize(t('reply'))}></th>
</tr>
</thead>
<tbody>
{posts.length === 0 && isLoading ? (
<tr className={styles.row}>
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
<LoadingEllipsis string={t('downloading_board')} />
</td>
</tr>
) : posts.length === 0 ? (
<tr className={styles.row}>
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
no posts
</td>
</tr>
) : (
posts.map((post, index) => {
const threadPath = getThreadPath(boardBasePath, post);
const fileLabel = getFileLabel(post.link);
const flashTag = getFlashTagOptionFromComment(post);
const subjectLabel = getSubjectLabel(post);
const rowClassName = `${styles.row} ${index % 2 === 0 ? styles.rowOdd : ''}`;
return (
<tr key={post.cid || `flash-post-${index}`} className={rowClassName}>
<td className={styles.numberCell}>
{threadPath ? (
<Link to={threadPath} className={styles.link}>
{getPostNumber(post)}
</Link>
) : (
getPostNumber(post)
)}
</td>
<td>{getDisplayName(post, anonymousLabel)}</td>
<td className={styles.fileCell} title={post.link || fileLabel}>
{post.link ? (
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
{fileLabel}
</a>
) : null}
</td>
<td className={styles.embedCell}>
{post.link ? (
<>
[
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
{capitalize(t('embed'))}
</a>
]
</>
) : null}
</td>
<td className={styles.tagCell} title={flashTag?.label}>
{flashTag ? `[${flashTag.shortLabel}]` : ''}
</td>
<td className={styles.subjectCell} title={subjectLabel}>
{subjectLabel}
</td>
<td className={styles.dateCell}>{typeof post.timestamp === 'number' ? getFormattedDate(post.timestamp) : ''}</td>
<td className={styles.repliesCell}>{getReplyCount(post)}</td>
<td className={styles.replyCell}>
{threadPath ? (
<>
[
<Link to={threadPath} className={styles.link}>
{capitalize(t('reply'))}
</Link>
]
</>
) : null}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
};
export default FlashBoardTable;
@@ -32,6 +32,7 @@ import FailedPublishNotice from '../failed-publish-notice';
import { canEmbed } from '../embed';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostMenuDesktop from './post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -418,6 +419,7 @@ const PostInfo = ({
)}
</span>
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
<PostFlashTag comment={post} directory={directoryEntry} />
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
<>
+23
View File
@@ -0,0 +1,23 @@
import { getFlashTagOptionFromComment, isFlashDirectory } from '../lib/flash-tags';
import type { DirectoryCommunity } from '../lib/utils/directory-list-utils';
import styles from '../views/post/post.module.css';
interface PostFlashTagProps {
comment: unknown;
directory: DirectoryCommunity | undefined;
}
const PostFlashTag = ({ comment, directory }: PostFlashTagProps) => {
if (!isFlashDirectory(directory)) return null;
const tag = getFlashTagOptionFromComment(comment);
if (!tag) return null;
return (
<span className={styles.flashTag} title={tag.label}>
[{tag.shortLabel}]{' '}
</span>
);
};
export default PostFlashTag;
@@ -73,11 +73,23 @@ vi.mock('react-i18next', async () => {
' before posting.',
);
}
if (i18nKey === 'post_form_flash_upload_prompt') {
return React.createElement(
React.Fragment,
{},
'Recommended SWF host: ',
components?.catbox ? React.cloneElement(components.catbox, {}, 'Catbox') : 'Catbox',
'. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link.',
);
}
return i18nKey;
},
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options?.domain ? `${key}:${options.domain}` : key),
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'choose_one') return 'Choose one:';
return options?.domain ? `${key}:${options.domain}` : key;
},
}),
};
});
@@ -433,6 +445,12 @@ describe('PostForm', () => {
{ 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: 'flash-posting.bso',
directoryCode: 'f',
features: { postFlairs: true, requirePostFlairs: true, requirePostLink: true, requirePostLinkIsMedia: false },
title: '/f/ - Flash',
},
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
@@ -704,6 +722,63 @@ describe('PostForm', () => {
});
});
it('shows /f/ upload guidance and publishes the selected flash tag as a post flair', async () => {
testState.resolvedCommunityAddress = 'flash-posting.bso';
await renderPostForm('/f');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const flashTagSelect = table?.querySelector<HTMLSelectElement>('select[name="flashTag"]');
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
const catboxLink = table?.querySelector<HTMLAnchorElement>('a[href="https://catbox.moe/"]');
expect(flashTagSelect).toBeTruthy();
expect(flashTagSelect?.value).toBe('');
expect(Array.from(flashTagSelect?.options || []).map((option) => option.textContent)).toEqual([
'Choose one:',
'Hentai',
'Porn',
'Japanese',
'Anime',
'Game',
'Loop',
'Other',
]);
expect(catboxLink?.textContent).toBe('Catbox');
expect(container.textContent).toContain('Recommended SWF host: Catbox');
await dispatchChange(flashTagSelect as HTMLSelectElement, 'loop');
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'flash thread',
flairs: [{ text: 'flash:loop' }],
});
});
it('does not publish a flash flair until a tag is selected', async () => {
testState.resolvedCommunityAddress = 'flash-posting.bso';
await renderPostForm('/f');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'flash thread',
});
});
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';
+56 -1
View File
@@ -25,6 +25,7 @@ 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, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityField } from '../../hooks/use-stable-community';
@@ -51,6 +52,11 @@ import debounce from 'lodash/debounce';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28;
const mergeFlairs = (...flairGroups: Array<Comment['flairs'] | undefined>): Comment['flairs'] | undefined => {
const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : []));
return flairs.length > 0 ? flairs : undefined;
};
const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => {
const raw = getPublishURLFilename(url) || uploadedFileName;
if (!raw) return noFileLabel;
@@ -130,6 +136,7 @@ interface PostFormFieldsProps {
subjectRef: React.Ref<HTMLInputElement>;
optionsRef: React.RefObject<HTMLInputElement>;
flagRef: React.RefObject<HTMLSelectElement>;
flashTagRef: React.RefObject<HTMLSelectElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
@@ -156,6 +163,9 @@ interface PostFormFieldsProps {
rulesPath: string;
requirePostLinkIsMedia: boolean;
flagOptions: CommentFlagSelectOption[];
flashTagOptions: FlashTagOption[];
showFlashTagSelector: boolean;
showFlashUploadPrompt: boolean;
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void;
@@ -177,6 +187,7 @@ const PostFormFields = ({
subjectRef,
optionsRef,
flagRef,
flashTagRef,
textRef,
urlRef,
url,
@@ -203,6 +214,9 @@ const PostFormFields = ({
rulesPath,
requirePostLinkIsMedia,
flagOptions,
flashTagOptions,
showFlashTagSelector,
showFlashUploadPrompt,
showBbcodeToolbar,
onBbcodePreviewToggle,
onPublishReply,
@@ -373,6 +387,21 @@ const PostFormFields = ({
</td>
</tr>
)}
{showFlashTagSelector && (
<tr>
<td>{t('tag')}</td>
<td>
<select name='flashTag' aria-label={t('tag')} ref={flashTagRef} defaultValue=''>
<option value=''>{t('choose_one')}</option>
{flashTagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
</tr>
)}
{showOekakiControls && (
<tr>
<td>Draw</td>
@@ -440,6 +469,16 @@ const PostFormFields = ({
}}
/>
</li>
{showFlashUploadPrompt && (
<li>
<Trans
i18nKey='post_form_flash_upload_prompt'
components={{
catbox: <a href='https://catbox.moe/' target='_blank' rel='noopener noreferrer' aria-label='Catbox' />,
}}
/>
</li>
)}
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
</ul>
</td>
@@ -467,6 +506,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const subjectRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const flashTagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
@@ -488,6 +528,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
@@ -536,6 +578,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
if (flagRef.current) {
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
}
if (flashTagRef.current) {
flashTagRef.current.value = '';
}
checkContentLength.cancel();
checkPostOptions.cancel();
fortuneEntryRef.current = null;
@@ -597,9 +642,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
const publishOptions = {
...flagPublishOptions,
...(flairs ? { flairs } : {}),
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...flagPublishOptions });
publishPost({ content: publishContent, ...publishOptions });
};
// redirect to pending page when pending comment is created
@@ -785,6 +836,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
subjectRef={subjectRef}
optionsRef={optionsRef}
flagRef={flagRef}
flashTagRef={flashTagRef}
textRef={textRef}
urlRef={urlRef}
url={url}
@@ -811,6 +863,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
rulesPath={rulesPath}
requirePostLinkIsMedia={requirePostLinkIsMedia}
flagOptions={flagOptions}
flashTagOptions={FLASH_TAG_OPTIONS}
showFlashTagSelector={showFlashTagSelector}
showFlashUploadPrompt={showFlashUploadPrompt}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
@@ -29,6 +29,7 @@ import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostMenuMobile from './post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -351,6 +352,7 @@ const PostInfoAndMedia = ({
</>
)}
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
<PostFlashTag comment={resolvedPost} directory={directoryEntry} />
{pinned && (
<span className={styles.stickyIconWrapper}>
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />
+32 -1
View File
@@ -2,7 +2,7 @@
"title": "5chan directories",
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
"createdAt": 1779182014,
"updatedAt": 1780054375,
"updatedAt": 1780123897,
"directories": [
{
"directoryCode": "a",
@@ -30,6 +30,37 @@
}
]
},
{
"directoryCode": "f",
"title": "/f/ - Flash",
"description": "Boards competing to host the /f/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-f-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"postFlairs": true,
"requirePostFlairs": true,
"requirePostLink": true,
"requirePostLinkIsMedia": false,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 50
},
"rules": [
"The tagging of uploaded files is mandatory. Improperly tagged items may be removed without notice. Abuse of the tagging system may result in temporary ban."
],
"createdAt": 1780123897,
"updatedAt": 1780123897,
"boards": [
{
"address": "flash-posting.bso",
"publicKey": "12D3KooWPFckNTD8YHVJrjpa9hRYvuomvM9VsvQQkJqjWRtpLv1F",
"owner": "plebeius.bso",
"addedAt": 1780123897
}
]
},
{
"directoryCode": "co",
"title": "/co/ - Comics & Cartoons",
+3 -3
View File
@@ -201,7 +201,7 @@ describe('use-directories', () => {
updatedAt: 2,
communities: [
{ address: 'music-posting.bso', title: '/mu/ - Cached Music', nsfw: false },
{ address: 'flash.bso', title: '/f/ - Flash', nsfw: true },
{ address: 'flash-posting.bso', title: '/f/ - Flash', nsfw: true },
],
};
@@ -225,8 +225,8 @@ describe('use-directories', () => {
expect(fetchMock).toHaveBeenCalled();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash-posting.bso']);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash-posting.bso']);
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata).toEqual({
title: 'Cached directories',
@@ -157,6 +157,7 @@ describe('useFileUpload', () => {
await act(async () => {
uploadPromise = hook().handleUpload();
});
expect((document.querySelector('input[type="file"]') as HTMLInputElement | null)?.accept).toContain('.swf');
await selectFileFromHiddenInput(selectedFile);
await act(async () => {
await uploadPromise;
+1 -1
View File
@@ -111,7 +111,7 @@ function selectFileViaInput(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,video/mp4,video/webm';
input.accept = 'image/jpeg,image/png,video/mp4,video/webm,.swf,application/x-shockwave-flash,application/vnd.adobe.flash.movie';
input.style.display = 'none';
let resolved = false;
let focusTimeoutId: number | null = null;
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { FLASH_TAG_OPTIONS, getFlashTagOption, getFlashTagOptionFromComment, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectory } from '../flash-tags';
describe('flash-tags', () => {
it('defines the classic /f/ tag options with text-only post flairs', () => {
expect(FLASH_TAG_OPTIONS.map((option) => option.label)).toEqual(['Hentai', 'Porn', 'Japanese', 'Anime', 'Game', 'Loop', 'Other']);
expect(FLASH_TAG_OPTIONS.map((option) => option.flair)).toEqual([
{ text: 'flash:hentai' },
{ text: 'flash:porn' },
{ text: 'flash:japanese' },
{ text: 'flash:anime' },
{ text: 'flash:game' },
{ text: 'flash:loop' },
{ text: 'flash:other' },
]);
});
it('publishes a flash tag only on /f/', () => {
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'loop')).toEqual({
flairs: [{ text: 'flash:loop' }],
});
expect(getFlashTagPublishOptionsForDirectoryCode('b', 'loop')).toEqual({
flairs: undefined,
});
});
it('does not publish a flair for missing or invalid selections', () => {
expect(getFlashTagOption(undefined)).toBeUndefined();
expect(getFlashTagOption('bad')).toBeUndefined();
expect(getFlashTagPublishOptionsForDirectoryCode('f', undefined)).toEqual({ flairs: undefined });
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'bad')).toEqual({ flairs: undefined });
});
it('detects flash directories from code or title', () => {
expect(isFlashDirectory({ directoryCode: 'f' })).toBe(true);
expect(isFlashDirectory({ title: '/f/ - Flash' })).toBe(true);
expect(isFlashDirectory({ directoryCode: 'b' })).toBe(false);
});
it('extracts display tags from comment flairs', () => {
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:loop' }] })?.shortLabel).toBe('L');
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flag:country:US' }] })).toBeUndefined();
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:bad' }] })).toBeUndefined();
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { DirectoryCommunity } from './utils/directory-list-utils';
export type FlashTagCode = 'hentai' | 'porn' | 'japanese' | 'anime' | 'game' | 'loop' | 'other';
export interface FlashTagOption {
value: FlashTagCode;
label: string;
shortLabel: string;
flair: {
text: `flash:${FlashTagCode}`;
};
}
export const FLASH_TAG_OPTIONS: FlashTagOption[] = [
{ value: 'hentai', label: 'Hentai', shortLabel: 'H', flair: { text: 'flash:hentai' } },
{ value: 'porn', label: 'Porn', shortLabel: 'P', flair: { text: 'flash:porn' } },
{ value: 'japanese', label: 'Japanese', shortLabel: 'J', flair: { text: 'flash:japanese' } },
{ value: 'anime', label: 'Anime', shortLabel: 'A', flair: { text: 'flash:anime' } },
{ value: 'game', label: 'Game', shortLabel: 'G', flair: { text: 'flash:game' } },
{ value: 'loop', label: 'Loop', shortLabel: 'L', flair: { text: 'flash:loop' } },
{ value: 'other', label: 'Other', shortLabel: '?', flair: { text: 'flash:other' } },
];
const FLASH_TAG_OPTIONS_BY_CODE = new Map(FLASH_TAG_OPTIONS.map((option) => [option.value, option]));
const getDirectoryCode = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined => {
const directoryCode = directory?.directoryCode?.trim().toLowerCase();
return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
};
export const isFlashDirectoryCode = (directoryCode: string | undefined): boolean => directoryCode?.toLowerCase() === 'f';
export const isFlashDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): boolean =>
isFlashDirectoryCode(getDirectoryCode(directory));
export const getFlashTagOption = (value: string | undefined): FlashTagOption | undefined => FLASH_TAG_OPTIONS_BY_CODE.get(value as FlashTagCode);
export const getFlashTagPublishOptionsForDirectoryCode = (directoryCode: string | undefined, value: string | undefined) => {
if (!isFlashDirectoryCode(directoryCode)) {
return { flairs: undefined };
}
const option = getFlashTagOption(value);
return { flairs: option ? [option.flair] : undefined };
};
export const getFlashTagOptionFromComment = (comment: unknown): FlashTagOption | undefined => {
const flairs = comment && typeof comment === 'object' && Array.isArray((comment as { flairs?: unknown }).flairs) ? (comment as { flairs: unknown[] }).flairs : [];
for (const flair of flairs) {
if (!flair || typeof flair !== 'object') continue;
const text = (flair as { text?: unknown }).text;
if (typeof text !== 'string') continue;
const match = text.match(/^flash:([a-z]+)$/);
if (!match) continue;
const option = FLASH_TAG_OPTIONS_BY_CODE.get(match[1] as FlashTagCode);
if (option) return option;
}
return undefined;
};
@@ -21,6 +21,11 @@ describe('direct-url', () => {
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
});
it('returns true for Flash movie extensions', () => {
expect(isDirectMediaUrl('https://example.com/movie.swf')).toBe(true);
expect(isDirectMediaUrl('https://example.com/movie.SWF?download=1')).toBe(true);
});
it('strips query strings and fragments before checking', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
+3 -3
View File
@@ -1,7 +1,7 @@
/** File extensions that denote direct media URLs (images + videos) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'] as const;
/** File extensions that denote direct media URLs (images, videos, and Flash movies) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv', '.swf'] as const;
/** Returns true if the URL appears to point to a direct media file (image or video) */
/** Returns true if the URL appears to point to a direct media file */
export function isDirectMediaUrl(url: string): boolean {
try {
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizeDirectoryList, sortDirectoryBoardsByRank } from '../directory-list-utils';
import { normalizeDirectoryList, sortDirectoryBoardsByRank, sortDirectoryLists } from '../directory-list-utils';
describe('directory-list-utils', () => {
it('preserves board scores and uses them for ranking', () => {
@@ -20,4 +20,36 @@ describe('directory-list-utils', () => {
]);
expect(sortDirectoryBoardsByRank(list?.boards ?? [])[0]?.address).toBe('higher-score.bso');
});
it('orders the Flash directory with the classic board set', () => {
expect(
sortDirectoryLists([
{ directoryCode: 'co', boards: [{ address: 'comics.bso' }] },
{ directoryCode: 'f', boards: [{ address: 'flash-posting.bso' }] },
{ directoryCode: 'a', boards: [{ address: 'anime.bso' }] },
]).map((list) => list.directoryCode),
).toEqual(['a', 'f', 'co']);
});
it('preserves rules from the directory list', () => {
const list = normalizeDirectoryList(
{
directoryCode: 'f',
rules: ['Tag uploaded files.'],
boards: [{ address: 'flash-posting.bso' }],
},
'f',
{
directories: {
f: {
directoryCode: 'f',
title: '/f/ - Flash',
rules: ['Ignored because defaults rules are not vendored.'],
},
},
},
);
expect(list?.rules).toEqual(['Tag uploaded files.']);
});
});
@@ -98,6 +98,7 @@ describe('media-utils', () => {
expect(getDisplayMediaInfoType('iframe', t)).toBe('translated:iframe');
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
expect(getDisplayMediaInfoType('swf', t)).toBe('SWF');
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
});
@@ -107,6 +108,7 @@ describe('media-utils', () => {
expect(getHasThumbnail({ type: 'video', url: 'https://example.com/file.mp4' }, 'https://example.com/file.mp4')).toBe(true);
expect(getHasThumbnail({ type: 'audio', url: 'https://example.com/file.mp3' }, 'https://example.com/file.mp3')).toBe(true);
expect(getHasThumbnail({ type: 'gif', url: 'https://example.com/file.gif' }, 'https://example.com/file.gif')).toBe(true);
expect(getHasThumbnail({ type: 'swf', url: 'https://example.com/file.swf' }, 'https://example.com/file.swf')).toBe(true);
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
expect(
getHasThumbnail(
@@ -132,6 +134,7 @@ describe('media-utils', () => {
expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' });
expect(getLinkMediaInfo('https://example.com/file.mp4')).toMatchObject({ type: 'video' });
expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' });
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
@@ -201,6 +204,7 @@ describe('media-utils', () => {
expect(getMediaDimensions({ type: 'audio', url: 'https://example.com/file.mp3' })).toBe('700x240');
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'image', url: 'https://example.com/file.png' })).toBe('640x480');
expect(getMediaDimensions({ linkHeight: 720, linkWidth: 1280, type: 'video', url: 'https://example.com/file.mp4' })).toBe('1280x720');
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'swf', url: 'https://example.com/file.swf' })).toBe('640x480');
expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe('');
});
+16
View File
@@ -45,6 +45,7 @@ export interface DirectoryList {
title?: string;
description?: string;
features?: DirectoryFeatures;
rules?: string[];
createdAt?: number;
updatedAt?: number;
boards: DirectoryListBoard[];
@@ -54,6 +55,7 @@ interface DirectoryDefaultsEntry {
directoryCode?: string;
title?: string;
features?: DirectoryFeatures;
rules?: string[];
}
export interface DirectoryDefaultsData {
@@ -66,6 +68,7 @@ export interface DirectoryDefaultsData {
const DIRECTORY_CODE_ORDER = [
'a',
'f',
'co',
'ck',
'pol',
@@ -121,6 +124,15 @@ const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const normalizeRules = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const rules = value.filter((rule): rule is string => typeof rule === 'string' && rule.length > 0);
return rules.length > 0 ? rules : undefined;
};
const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => {
if (!isRecord(raw)) {
return { directoryCode: code };
@@ -128,10 +140,12 @@ const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryD
const directoryCode = toString(raw.directoryCode) ?? code;
const features = normalizeFeatures(raw.features);
const rules = normalizeRules(raw.rules);
return {
directoryCode,
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(features ? { features } : {}),
...(rules ? { rules } : {}),
};
};
@@ -218,12 +232,14 @@ export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defau
const defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode];
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
const rules = normalizeRules(raw.rules);
return {
directoryCode,
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(features ? { features } : {}),
...(rules ? { rules } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
+8 -3
View File
@@ -33,6 +33,8 @@ export const getDisplayMediaInfoType = (type: string, t: Translate) => {
return t('video');
case 'audio':
return t('audio');
case 'swf':
return 'SWF';
default:
return t('webpage');
}
@@ -44,7 +46,7 @@ export const getHasThumbnail = memoize(
const { type, thumbnail, patternThumbnailUrl } = commentMediaInfo;
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif') return true;
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif' || type === 'swf') return true;
if (type === 'webpage' && thumbnail) return true;
if (type === 'iframe' && (patternThumbnailUrl || thumbnail)) return true;
@@ -79,7 +81,8 @@ const getPatternThumbnailUrl = (url: URL): string | undefined => {
const KNOWN_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'];
const KNOWN_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v'];
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS]);
const KNOWN_SWF_EXTENSIONS = ['swf'];
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
@@ -154,6 +157,8 @@ export const getLinkMediaInfo = memoize(
type = 'video';
} else if (KNOWN_AUDIO_EXTENSIONS.includes(extension)) {
type = 'audio';
} else if (KNOWN_SWF_EXTENSIONS.includes(extension)) {
type = 'swf';
}
// Unknown extensions remain as 'webpage'
@@ -297,7 +302,7 @@ export const getMediaDimensions = memoize(
}
} else if (type === 'audio') {
return '700x240';
} else if (type === 'image' || type === 'video' || type === 'gif') {
} else if (type === 'image' || type === 'video' || type === 'gif' || type === 'swf') {
if (linkWidth && linkHeight) {
return `${linkWidth}x${linkHeight}`;
}
+23
View File
@@ -0,0 +1,23 @@
declare module '@ruffle-rs/ruffle' {
const ruffle: unknown;
export default ruffle;
}
interface RufflePlayerElement extends HTMLElement {
ruffle?: () => {
load: (source: string | Record<string, unknown>) => Promise<void> | void;
};
}
interface RuffleSource {
createPlayer?: () => RufflePlayerElement;
}
interface RufflePlayerApi {
config?: Record<string, unknown>;
newest?: () => RuffleSource | null;
}
interface Window {
RufflePlayer?: RufflePlayerApi;
}
+124 -1
View File
@@ -10,17 +10,26 @@ import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../.
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
type TestComment = {
author?: {
displayName?: string;
};
cid?: string;
content?: string;
flairs?: Array<{ text?: string }>;
index?: number;
link?: string;
number?: number | string;
parentCid?: string;
pinned?: boolean;
postNumber?: number | string;
communityAddress?: string;
deleted?: boolean;
postCid?: string;
replyCount?: number;
removed?: boolean;
state?: string;
timestamp?: number;
title?: string;
};
type TestCommunity = {
@@ -42,7 +51,7 @@ const testState = vi.hoisted(() => ({
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
feedState: undefined as string | undefined,
@@ -539,6 +548,120 @@ describe('Board', () => {
expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true);
});
it('renders flash board posts as table rows instead of the normal feed', async () => {
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
testState.directoryByAddress = {
'flash-posting.bso': {
address: 'flash-posting.bso',
directoryCode: 'f',
features: { postsPerPage: 50 },
title: '/f/ - Flash',
},
};
testState.resolvedCommunityAddress = 'flash-posting.bso';
testState.community = {
error: undefined,
shortAddress: 'flash-posting.bso',
state: 'ready',
title: '/f/ - Flash',
};
testState.communitySnapshot = {
shortAddress: 'flash-posting.bso',
title: '/f/ - Flash',
};
testState.hasMore = true;
testState.feed = [
{
author: { displayName: 'FlashAnon' },
cid: 'flash-cid',
communityAddress: 'flash-posting.bso',
flairs: [{ text: 'flash:game' }],
link: 'https://files.catbox.moe/movie.swf',
number: 3524333,
postCid: 'flash-cid',
replyCount: 4,
timestamp: 1_704_067_200,
title: 'Flash game',
},
];
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
const table = container.querySelector('#flash-list');
expect(table).toBeTruthy();
expect(container.querySelector('[data-testid="post"]')).toBeNull();
expect(table?.querySelectorAll('tbody tr').length).toBe(1);
expect(table?.textContent).toContain('3524333');
expect(table?.textContent).toContain('FlashAnon');
expect(table?.textContent).toContain('movie.swf');
expect(table?.textContent).toContain('[G]');
expect(table?.textContent).toContain('Flash game');
expect(table?.textContent).toContain('4');
expect(table?.querySelector('a[href="/f/thread/flash-cid"]')?.textContent).toBe('3524333');
expect(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more')).toBeUndefined();
});
it('renders an empty flash table when the board has no posts', async () => {
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
testState.directoryByAddress = {
'flash-posting.bso': {
address: 'flash-posting.bso',
directoryCode: 'f',
features: { postsPerPage: 50 },
title: '/f/ - Flash',
},
};
testState.resolvedCommunityAddress = 'flash-posting.bso';
testState.community = {
error: undefined,
shortAddress: 'flash-posting.bso',
state: 'succeeded',
title: '/f/ - Flash',
};
testState.communitySnapshot = {
shortAddress: 'flash-posting.bso',
title: '/f/ - Flash',
};
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
const table = container.querySelector('#flash-list');
expect(table).toBeTruthy();
expect(container.querySelector('[data-testid="post"]')).toBeNull();
expect(table?.textContent).toContain('no posts');
});
it('keeps the flash table in loading state until the empty board feed finishes syncing', async () => {
testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }];
testState.directoryByAddress = {
'flash-posting.bso': {
address: 'flash-posting.bso',
directoryCode: 'f',
features: { postsPerPage: 50 },
title: '/f/ - Flash',
},
};
testState.resolvedCommunityAddress = 'flash-posting.bso';
testState.community = {
error: undefined,
shortAddress: 'flash-posting.bso',
state: 'ready',
title: '/f/ - Flash',
};
testState.communitySnapshot = {
shortAddress: 'flash-posting.bso',
title: '/f/ - Flash',
};
testState.hasMore = true;
await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' });
const table = container.querySelector('#flash-list');
expect(table).toBeTruthy();
expect(table?.textContent).not.toContain('no posts');
expect(table?.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board');
});
it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [
+18 -4
View File
@@ -28,7 +28,9 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import { isFlashDirectory, isFlashDirectoryCode } from '../../lib/flash-tags';
import ErrorDisplay from '../../components/error-display/error-display';
import FlashBoardTable from '../../components/flash-board-table/flash-board-table';
import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
import { CatalogButton } from '../../components/board-buttons/board-buttons';
@@ -193,12 +195,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const communities = useCommunityIdentifiers(communityAddresses);
const communityIdentifier = useCommunityIdentifier(communityAddress);
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const requestedBoardIdentifier = boardIdentifierProp || params.boardIdentifier;
const shouldUseFlashTable = !isMultiboardView && (isFlashDirectoryCode(requestedBoardIdentifier) || isFlashDirectory(communityDirectory));
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
const isMobile = useIsMobile();
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const effectiveInfiniteScroll = !shouldUseFlashTable && (enableInfiniteScroll || isForcedInfiniteScroll);
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
const excludeArchivedFilter = useMemo(
@@ -524,7 +528,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
</div>
</>
)}
{hasMore && !effectiveInfiniteScroll && (
{hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
<div className={mobileFooterStyles.mobileFooterButtons}>
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
{t('load_more')}
@@ -553,6 +557,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subscriptions?.length,
accountCommunityAddresses?.length,
effectiveInfiniteScroll,
shouldUseFlashTable,
isForcedInfiniteScroll,
paginationBasePath,
currentPage,
@@ -635,6 +640,10 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
communityIdentifier.publicKey.length > 0 &&
communityData?.nameResolved === false;
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const shouldShowFlashTableLoading =
shouldUseFlashTable && displayFeed.length === 0 && !(isLoadedCommunityState && isFeedSucceeded) && communityState !== 'failed' && feedState !== 'failed';
return (
<>
@@ -646,7 +655,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
</div>
)}
{shouldShowUnverifiedAddressWarning && <output className={styles.addressWarning}>{t('board_address_unverified_warning')}</output>}
{effectiveInfiniteScroll ? (
{shouldUseFlashTable ? (
<>
<FlashBoardTable boardBasePath={paginationBasePath} isLoading={shouldShowFlashTableLoading} posts={displayFeed} />
<footerComponents.Footer />
</>
) : effectiveInfiniteScroll ? (
<Virtuoso
defaultItemHeight={defaultBoardItemHeight}
{...boardSizingProps}
+4
View File
@@ -148,6 +148,10 @@
vertical-align: -1px;
}
.flashTag {
font-weight: bold;
}
.authorFlag {
display: inline-block;
flex: 0 0 auto;