; 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 = [
diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx
index b96425e6..f8035e50 100644
--- a/src/views/board/board.tsx
+++ b/src/views/board/board.tsx
@@ -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
>
)}
- {hasMore && !effectiveInfiniteScroll && (
+ {hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
)}
{shouldShowUnverifiedAddressWarning && }
- {effectiveInfiniteScroll ? (
+ {shouldUseFlashTable ? (
+ <>
+
+
+ >
+ ) : effectiveInfiniteScroll ? (
ruffleRuntimeFilePattern.test(fileName));
+}
+
+function getRuffleRuntimeContentType(fileName) {
+ return fileName.endsWith('.wasm') ? 'application/wasm' : 'application/javascript; charset=utf-8';
+}
+
+function ruffleRuntimeAssetsPlugin() {
+ return {
+ name: 'fivechan-ruffle-runtime-assets',
+ configureServer(server) {
+ server.middlewares.use('/ruffle/', (req, res, next) => {
+ const fileName = new URL(req.url || '', 'https://example.invalid/').pathname.split('/').pop() || '';
+
+ if (!ruffleRuntimeFilePattern.test(fileName)) {
+ next();
+ return;
+ }
+
+ try {
+ const source = readFileSync(new URL(fileName, ruffleRuntimeDirectory));
+ res.setHeader('Content-Type', getRuffleRuntimeContentType(fileName));
+ res.setHeader('Cache-Control', 'no-cache');
+ res.end(source);
+ } catch {
+ next();
+ }
+ });
+ },
+ generateBundle() {
+ for (const fileName of getRuffleRuntimeFileNames()) {
+ this.emitFile({
+ type: 'asset',
+ fileName: `ruffle/${fileName}`,
+ source: readFileSync(new URL(fileName, ruffleRuntimeDirectory)),
+ });
+ }
+ },
+ };
+}
+
function getVercelContentSecurityPolicy() {
const vercelConfig = JSON.parse(readFileSync(new URL('./vercel.json', import.meta.url), 'utf8'));
const cspHeader = vercelConfig.headers
@@ -228,9 +272,7 @@ function verifyVercelCspHashesPlugin() {
closeBundle() {
const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8');
const contentSecurityPolicy = getVercelContentSecurityPolicy();
- const missingHashes = getInlineScriptHashes(indexHtml).filter(
- (hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash),
- );
+ const missingHashes = getInlineScriptHashes(indexHtml).filter((hash) => !contentSecurityPolicy.includes(`'${hash}'`) && !contentSecurityPolicy.includes(hash));
if (missingHashes.length > 0) {
const plural = missingHashes.length === 1 ? '' : 'es';
@@ -280,6 +322,7 @@ function adaptReactPluginForRolldown(plugin) {
export default defineConfig({
plugins: [
appVersionMetadataPlugin(),
+ ruffleRuntimeAssetsPlugin(),
...react({
babel: {
plugins: [
diff --git a/yarn.lock b/yarn.lock
index 978adaf7..300646b5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -28,6 +28,7 @@ __metadata:
"@pkcprotocol/pkc-js": "npm:0.0.37"
"@react-spring/web": "npm:10.0.3"
"@reforged/maker-appimage": "npm:5.1.1"
+ "@ruffle-rs/ruffle": "npm:0.2.0"
"@types/lodash": "npm:4.17.24"
"@types/memoizee": "npm:0.4.9"
"@types/node": "npm:20.19.37"
@@ -6386,6 +6387,13 @@ __metadata:
languageName: node
linkType: hard
+"@ruffle-rs/ruffle@npm:0.2.0":
+ version: 0.2.0
+ resolution: "@ruffle-rs/ruffle@npm:0.2.0"
+ checksum: 10c0/5fc32eebc111743f6273ca238bee18fb7dd510ca05f81a3f6d6530d8c1b91250d0fcf2a9a23225be1d5d0ffd6000fc62b9531b59719b937399fc11fc088020cb
+ languageName: node
+ linkType: hard
+
"@scure/base@npm:~1.2.5":
version: 1.2.6
resolution: "@scure/base@npm:1.2.6"