mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(flash board): add SWF posting support (#1145)
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')} />
|
||||
|
||||
Reference in New Issue
Block a user