mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'master' of github.com:bitsocialnet/5chan
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -148,6 +148,10 @@
|
||||
vertical-align: -1px;
|
||||
}
|
||||
|
||||
.flashTag {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.authorFlag {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user