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

This commit is contained in:
Tommaso Casaburi
2026-05-30 16:07:41 +07:00
committed by GitHub
parent 56894700c1
commit 9b3a95dd95
69 changed files with 1372 additions and 63 deletions
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
hostname: 'example.com',
isMobile: false,
unmuteExpandedVideoSound: false,
ruffleLoadMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
@@ -69,6 +70,8 @@ vi.mock('../../embed', () => ({
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
}));
vi.mock('@ruffle-rs/ruffle', () => ({}));
let container: HTMLDivElement;
let root: Root;
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
@@ -90,6 +93,16 @@ describe('CommentMedia', () => {
testState.hostname = 'example.com';
testState.isMobile = false;
testState.unmuteExpandedVideoSound = false;
testState.ruffleLoadMock = vi.fn();
const player = document.createElement('ruffle-player') as HTMLElement & {
ruffle: () => { load: (source: string | Record<string, unknown>) => void };
};
player.ruffle = () => ({ load: testState.ruffleLoadMock });
window.RufflePlayer = {
newest: () => ({
createPlayer: () => player,
}),
};
setShowThumbnailMock = vi.fn();
container = document.createElement('div');
@@ -100,6 +113,7 @@ describe('CommentMedia', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
delete window.RufflePlayer;
});
it('toggles image expansion on mobile and renders the media metadata', async () => {
@@ -370,4 +384,52 @@ describe('CommentMedia', () => {
expect(video).toBeTruthy();
expect(video?.muted).toBe(true);
});
it('renders collapsed SWF media as a compact placeholder and expands through Ruffle', async () => {
await renderMedia({
commentMediaInfo: {
type: 'swf',
url: 'https://cdn.example.com/movie.swf',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
const placeholder = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '[SWF]');
expect(placeholder).toBeTruthy();
expect(container.querySelector('object')).toBeNull();
expect(container.querySelector('embed')).toBeNull();
await act(async () => {
placeholder?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(setShowThumbnailMock).toHaveBeenCalledWith(false);
await renderMedia({
commentMediaInfo: {
type: 'swf',
url: 'https://cdn.example.com/movie.swf',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: false,
});
await act(async () => {
await Promise.resolve();
});
expect(container.querySelector('[data-testid="ruffle-player"]')).toBeTruthy();
expect(testState.ruffleLoadMock).toHaveBeenCalledWith(
expect.objectContaining({
allowNetworking: 'internal',
allowScriptAccess: false,
autoplay: 'off',
openUrlMode: 'confirm',
url: 'https://cdn.example.com/movie.swf',
}),
);
expect(container.querySelector('object')).toBeNull();
expect(container.querySelector('embed')).toBeNull();
});
});
@@ -147,11 +147,77 @@
cursor: pointer;
}
.swfPlaceholder {
display: inline-block;
width: 100%;
border: 0;
background: transparent;
color: var(--post-link-text-color);
font-family: inherit;
font-size: 9pt;
line-height: 1.2;
padding: 0;
text-align: center;
text-decoration: var(--post-link-text-decoration);
cursor: pointer;
}
.swfPlaceholder:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.content iframe {
border: none;
color-scheme: light;
}
.rufflePlayer {
display: inline-flex;
align-items: center;
justify-content: center;
width: min(640px, 100%);
height: 480px;
max-width: 100%;
border: var(--reply-desktop-border);
background: var(--media-thumbnail-background-color);
box-sizing: border-box;
line-height: 1.2;
}
.rufflePlayerElement {
display: block;
width: 100%;
height: 100%;
}
.rufflePlayerMount {
display: block;
width: 100%;
height: 100%;
}
.rufflePlayerMountHidden {
display: none;
}
.ruffleLoading,
.ruffleFallback {
padding: 4px;
color: var(--post-mobile-file-info-text-color);
font-size: 9pt;
}
.ruffleFallback a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.ruffleFallback a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.fileInfo {
padding-bottom: 5px;
text-align: center;
+10 -1
View File
@@ -7,6 +7,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsMobile from '../../hooks/use-is-mobile';
import styles from './comment-media.module.css';
import Embed, { canEmbed } from '../embed';
import RufflePlayer from './ruffle-player';
interface MediaProps {
commentMediaInfo?: CommentMediaInfo;
@@ -143,6 +144,12 @@ const Thumbnail = ({
) : null;
} else if (type === 'audio') {
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
} else if (type === 'swf') {
thumbnailComponent = (
<button type='button' className={styles.swfPlaceholder} onClick={() => setShowThumbnail(false)}>
[SWF]
</button>
);
}
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
@@ -219,6 +226,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
)
) : type === 'video' ? (
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
) : type === 'swf' && url ? (
<RufflePlayer url={url} />
) : type === 'webpage' ? (
disableToggle ? (
<img src={thumbnail} alt='' />
@@ -237,7 +246,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
{mediaDimensions && `, ${mediaDimensions}`})
</div>
)}
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio' || type === 'swf') && (
<div className={styles.closeButton}>
<button
type='button'
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './comment-media.module.css';
const RUFFLE_CONFIG = {
allowFullscreen: false,
allowNetworking: 'internal',
allowScriptAccess: false,
autoplay: 'off',
openUrlMode: 'confirm',
polyfills: false,
showSwfDownload: false,
} as const;
let ruffleLoadPromise: Promise<void> | undefined;
const getRufflePublicPath = () => new URL('ruffle/', document.baseURI).href;
const getRuffleConfig = () => ({
...window.RufflePlayer?.config,
...RUFFLE_CONFIG,
publicPath: getRufflePublicPath(),
});
const loadRuffle = async () => {
if (!ruffleLoadPromise) {
window.RufflePlayer = window.RufflePlayer || {};
window.RufflePlayer.config = getRuffleConfig();
ruffleLoadPromise = import('@ruffle-rs/ruffle')
.then(() => undefined)
.catch((error: unknown) => {
ruffleLoadPromise = undefined;
throw error;
});
}
return ruffleLoadPromise;
};
interface RufflePlayerProps {
url: string;
}
type RufflePlayerStatus = 'loading' | 'ready' | 'failed';
const RufflePlayer = ({ url }: RufflePlayerProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLSpanElement>(null);
const [status, setStatus] = useState<RufflePlayerStatus>('loading');
useEffect(() => {
let cancelled = false;
let player: RufflePlayerElement | undefined;
const mountNode = containerRef.current;
const mountPlayer = async () => {
setStatus('loading');
if (mountNode) {
mountNode.textContent = '';
}
try {
await loadRuffle();
if (cancelled || !mountNode) {
return;
}
const ruffle = window.RufflePlayer?.newest?.();
player = ruffle?.createPlayer?.();
if (!player) {
throw new Error('Ruffle player API unavailable');
}
player.className = styles.rufflePlayerElement;
player.setAttribute('data-testid', 'ruffle-player');
mountNode.appendChild(player);
const playerApi = player.ruffle?.();
if (!playerApi) {
throw new Error('Ruffle player instance unavailable');
}
await playerApi.load({
...getRuffleConfig(),
url,
});
if (!cancelled) {
setStatus('ready');
}
} catch (error) {
console.error('Error loading SWF with Ruffle:', error);
if (!cancelled) {
setStatus('failed');
}
}
};
void mountPlayer();
return () => {
cancelled = true;
player?.remove();
if (mountNode) {
mountNode.textContent = '';
}
};
}, [url]);
return (
<span className={styles.rufflePlayer}>
<span className={status === 'ready' ? styles.rufflePlayerMount : styles.rufflePlayerMountHidden} ref={containerRef} />
{status === 'failed' ? (
<span className={styles.ruffleFallback}>
{t('media_failed_to_load')}.{' '}
<a href={url} target='_blank' rel='noopener noreferrer'>
{t('media_failed_to_load_open_source')}
</a>
</span>
) : status === 'loading' ? (
<span className={styles.ruffleLoading}>{t('loading')} SWF</span>
) : null}
</span>
);
};
export default RufflePlayer;
@@ -0,0 +1,166 @@
.wrapper {
overflow-x: auto;
padding: 0 5px;
}
.flashListing {
min-width: 760px;
width: 80%;
max-width: 810px;
margin: 10px auto 0;
border-collapse: separate;
border-spacing: 1px;
table-layout: auto;
}
.flashListing td,
.flashListing th {
padding: 2px;
font-size: 12px;
font-weight: normal;
text-align: center;
}
.flashListing thead th {
background: #98e;
border: 1px solid #000;
font-weight: 700;
}
.flashListing thead th:nth-child(1) {
width: 6.5%;
}
.flashListing thead th:nth-child(2) {
width: 11%;
}
.flashListing thead th:nth-child(3) {
width: 20%;
}
.flashListing thead th:nth-child(4) {
width: 7%;
}
.flashListing thead th:nth-child(5) {
width: 5%;
}
.flashListing thead th:nth-child(6) {
width: 23%;
}
.flashListing thead th:nth-child(7) {
width: 16%;
}
.flashListing thead th:nth-child(8) {
width: 7%;
}
.flashListing thead th:nth-child(9) {
width: 4.5%;
}
.postblock {
padding: 5px !important;
text-align: center;
}
.row {
background: transparent;
}
.numberCell,
.embedCell,
.tagCell,
.dateCell,
.repliesCell,
.replyCell {
white-space: nowrap;
}
.fileCell,
.subjectCell {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subjectCell {
text-align: left !important;
}
.emptyCell {
padding: 8px 2px !important;
text-align: center;
}
.link {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.link:hover {
color: var(--post-link-text-color-hover);
}
@media (max-width: 640px) {
.wrapper {
padding: 0;
}
.flashListing {
width: calc(100% - 10px);
max-width: none;
margin: 10px auto 0 auto;
}
}
:global(body.yotsuba) .rowOdd td {
background: #ede2d4;
}
:global(body.yotsuba) .flashListing thead th {
background: #ea8;
}
:global(body.yotsuba-b) .rowOdd td {
background: #e0e5f6;
}
:global(body.futaba) .rowOdd td {
background: #ede2d4;
}
:global(body.futaba) .flashListing thead th {
background: #f0e0d6;
}
:global(body.burichan) .rowOdd td {
background: #e0e5f6;
}
:global(body.burichan) .flashListing thead th {
background: #c3c9e9;
}
:global(body.tomorrow) .rowOdd td {
background: rgba(255, 255, 255, 0.1);
}
:global(body.tomorrow) .flashListing thead th {
background: var(--post-form-field-title-background-color);
color: var(--post-form-field-title-color);
border: none;
}
:global(body.photon) .rowOdd td {
background: #888;
}
:global(body.photon) .flashListing thead th {
background: #ddd;
}
@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import capitalize from 'lodash/capitalize';
import { getFlashTagOptionFromComment } from '../../lib/flash-tags';
import { removeMarkdown } from '../../lib/utils/post-utils';
import { getFormattedDate } from '../../lib/utils/time-utils';
import { getPublishURLFilename } from '../../lib/utils/url-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import LoadingEllipsis from '../loading-ellipsis';
import styles from './flash-board-table.module.css';
type FlashBoardComment = Comment & {
postNumber?: number | string;
};
interface FlashBoardTableProps {
boardBasePath: string;
isLoading?: boolean;
posts: FlashBoardComment[];
}
const CELL_COUNT = 9;
const MAX_CELL_TEXT_LENGTH = 40;
const MAX_SUBJECT_LENGTH = 55;
const getThreadPath = (boardBasePath: string, comment: FlashBoardComment) => {
const threadCid = comment.postCid || comment.cid;
return threadCid ? `${boardBasePath.replace(/\/$/, '')}/thread/${threadCid}` : undefined;
};
const getPostNumber = (comment: FlashBoardComment) => comment.number || comment.postNumber || '?';
const getDisplayName = (comment: FlashBoardComment, anonymousLabel: string) => comment.author?.displayName?.trim() || anonymousLabel;
const getFileLabel = (link: string | undefined) => {
if (!link) {
return '';
}
return truncateWithEllipsisInMiddle(getPublishURLFilename(link) || link, MAX_CELL_TEXT_LENGTH);
};
const getSubjectLabel = (comment: FlashBoardComment) => {
const title = typeof comment.title === 'string' ? removeMarkdown(comment.title).trim() : '';
const content = typeof comment.content === 'string' ? removeMarkdown(comment.content).trim() : '';
return truncateWithEllipsisInMiddle(title || content, MAX_SUBJECT_LENGTH);
};
const getReplyCount = (comment: FlashBoardComment) => (typeof comment.replyCount === 'number' ? comment.replyCount : 0);
const FlashBoardTable = ({ boardBasePath, isLoading = false, posts }: FlashBoardTableProps) => {
const { t } = useTranslation();
const anonymousLabel = capitalize(t('anonymous'));
return (
<div className={styles.wrapper}>
<table id='flash-list' className={styles.flashListing}>
<thead>
<tr>
<th scope='col' className={styles.postblock}>
No.
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('name'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('file'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('embed'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('tag'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('subject'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('date'))}
</th>
<th scope='col' className={styles.postblock}>
{capitalize(t('replies'))}
</th>
<th scope='col' className={styles.postblock} aria-label={capitalize(t('reply'))}></th>
</tr>
</thead>
<tbody>
{posts.length === 0 && isLoading ? (
<tr className={styles.row}>
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
<LoadingEllipsis string={t('downloading_board')} />
</td>
</tr>
) : posts.length === 0 ? (
<tr className={styles.row}>
<td colSpan={CELL_COUNT} className={styles.emptyCell}>
no posts
</td>
</tr>
) : (
posts.map((post, index) => {
const threadPath = getThreadPath(boardBasePath, post);
const fileLabel = getFileLabel(post.link);
const flashTag = getFlashTagOptionFromComment(post);
const subjectLabel = getSubjectLabel(post);
const rowClassName = `${styles.row} ${index % 2 === 0 ? styles.rowOdd : ''}`;
return (
<tr key={post.cid || `flash-post-${index}`} className={rowClassName}>
<td className={styles.numberCell}>
{threadPath ? (
<Link to={threadPath} className={styles.link}>
{getPostNumber(post)}
</Link>
) : (
getPostNumber(post)
)}
</td>
<td>{getDisplayName(post, anonymousLabel)}</td>
<td className={styles.fileCell} title={post.link || fileLabel}>
{post.link ? (
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
{fileLabel}
</a>
) : null}
</td>
<td className={styles.embedCell}>
{post.link ? (
<>
[
<a href={post.link} target='_blank' rel='noopener noreferrer' className={styles.link}>
{capitalize(t('embed'))}
</a>
]
</>
) : null}
</td>
<td className={styles.tagCell} title={flashTag?.label}>
{flashTag ? `[${flashTag.shortLabel}]` : ''}
</td>
<td className={styles.subjectCell} title={subjectLabel}>
{subjectLabel}
</td>
<td className={styles.dateCell}>{typeof post.timestamp === 'number' ? getFormattedDate(post.timestamp) : ''}</td>
<td className={styles.repliesCell}>{getReplyCount(post)}</td>
<td className={styles.replyCell}>
{threadPath ? (
<>
[
<Link to={threadPath} className={styles.link}>
{capitalize(t('reply'))}
</Link>
]
</>
) : null}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
};
export default FlashBoardTable;
@@ -32,6 +32,7 @@ import FailedPublishNotice from '../failed-publish-notice';
import { canEmbed } from '../embed';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostMenuDesktop from './post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -418,6 +419,7 @@ const PostInfo = ({
)}
</span>
<PostAuthorFlags author={author} comment={post} enabled={showAuthorFlags} />
<PostFlashTag comment={post} directory={directoryEntry} />
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
<>
+23
View File
@@ -0,0 +1,23 @@
import { getFlashTagOptionFromComment, isFlashDirectory } from '../lib/flash-tags';
import type { DirectoryCommunity } from '../lib/utils/directory-list-utils';
import styles from '../views/post/post.module.css';
interface PostFlashTagProps {
comment: unknown;
directory: DirectoryCommunity | undefined;
}
const PostFlashTag = ({ comment, directory }: PostFlashTagProps) => {
if (!isFlashDirectory(directory)) return null;
const tag = getFlashTagOptionFromComment(comment);
if (!tag) return null;
return (
<span className={styles.flashTag} title={tag.label}>
[{tag.shortLabel}]{' '}
</span>
);
};
export default PostFlashTag;
@@ -73,11 +73,23 @@ vi.mock('react-i18next', async () => {
' before posting.',
);
}
if (i18nKey === 'post_form_flash_upload_prompt') {
return React.createElement(
React.Fragment,
{},
'Recommended SWF host: ',
components?.catbox ? React.cloneElement(components.catbox, {}, 'Catbox') : 'Catbox',
'. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link.',
);
}
return i18nKey;
},
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options?.domain ? `${key}:${options.domain}` : key),
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'choose_one') return 'Choose one:';
return options?.domain ? `${key}:${options.domain}` : key;
},
}),
};
});
@@ -433,6 +445,12 @@ describe('PostForm', () => {
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
{ address: 'politically-incorrect.bso', directoryCode: 'pol', features: { hasFlags: true }, title: '/pol/ - Politically Incorrect' },
{ address: 'random-nsfw.bso', features: {}, title: '/b/ - Random' },
{
address: 'flash-posting.bso',
directoryCode: 'f',
features: { postFlairs: true, requirePostFlairs: true, requirePostLink: true, requirePostLinkIsMedia: false },
title: '/f/ - Flash',
},
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
@@ -704,6 +722,63 @@ describe('PostForm', () => {
});
});
it('shows /f/ upload guidance and publishes the selected flash tag as a post flair', async () => {
testState.resolvedCommunityAddress = 'flash-posting.bso';
await renderPostForm('/f');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const flashTagSelect = table?.querySelector<HTMLSelectElement>('select[name="flashTag"]');
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
const catboxLink = table?.querySelector<HTMLAnchorElement>('a[href="https://catbox.moe/"]');
expect(flashTagSelect).toBeTruthy();
expect(flashTagSelect?.value).toBe('');
expect(Array.from(flashTagSelect?.options || []).map((option) => option.textContent)).toEqual([
'Choose one:',
'Hentai',
'Porn',
'Japanese',
'Anime',
'Game',
'Loop',
'Other',
]);
expect(catboxLink?.textContent).toBe('Catbox');
expect(container.textContent).toContain('Recommended SWF host: Catbox');
await dispatchChange(flashTagSelect as HTMLSelectElement, 'loop');
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'flash thread',
flairs: [{ text: 'flash:loop' }],
});
});
it('does not publish a flash flair until a tag is selected', async () => {
testState.resolvedCommunityAddress = 'flash-posting.bso';
await renderPostForm('/f');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const linkInput = Array.from(table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || []).find((input) => input.getAttribute('aria-label') === 'link');
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(linkInput as HTMLInputElement, 'https://files.catbox.moe/movie.swf');
await dispatchInput(textarea as HTMLTextAreaElement, 'flash thread');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'flash thread',
});
});
it('validates unsupported options and stores fortune output in post content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'random-nsfw.bso';
+56 -1
View File
@@ -25,6 +25,7 @@ import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath } from '../../lib/utils/route-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityField } from '../../hooks/use-stable-community';
@@ -51,6 +52,11 @@ import debounce from 'lodash/debounce';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28;
const mergeFlairs = (...flairGroups: Array<Comment['flairs'] | undefined>): Comment['flairs'] | undefined => {
const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : []));
return flairs.length > 0 ? flairs : undefined;
};
const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | null | undefined, noFileLabel: string): string => {
const raw = getPublishURLFilename(url) || uploadedFileName;
if (!raw) return noFileLabel;
@@ -130,6 +136,7 @@ interface PostFormFieldsProps {
subjectRef: React.Ref<HTMLInputElement>;
optionsRef: React.RefObject<HTMLInputElement>;
flagRef: React.RefObject<HTMLSelectElement>;
flashTagRef: React.RefObject<HTMLSelectElement>;
textRef: React.RefObject<HTMLTextAreaElement>;
urlRef: React.Ref<HTMLInputElement>;
url: string;
@@ -156,6 +163,9 @@ interface PostFormFieldsProps {
rulesPath: string;
requirePostLinkIsMedia: boolean;
flagOptions: CommentFlagSelectOption[];
flashTagOptions: FlashTagOption[];
showFlashTagSelector: boolean;
showFlashUploadPrompt: boolean;
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void;
@@ -177,6 +187,7 @@ const PostFormFields = ({
subjectRef,
optionsRef,
flagRef,
flashTagRef,
textRef,
urlRef,
url,
@@ -203,6 +214,9 @@ const PostFormFields = ({
rulesPath,
requirePostLinkIsMedia,
flagOptions,
flashTagOptions,
showFlashTagSelector,
showFlashUploadPrompt,
showBbcodeToolbar,
onBbcodePreviewToggle,
onPublishReply,
@@ -373,6 +387,21 @@ const PostFormFields = ({
</td>
</tr>
)}
{showFlashTagSelector && (
<tr>
<td>{t('tag')}</td>
<td>
<select name='flashTag' aria-label={t('tag')} ref={flashTagRef} defaultValue=''>
<option value=''>{t('choose_one')}</option>
{flashTagOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
</tr>
)}
{showOekakiControls && (
<tr>
<td>Draw</td>
@@ -440,6 +469,16 @@ const PostFormFields = ({
}}
/>
</li>
{showFlashUploadPrompt && (
<li>
<Trans
i18nKey='post_form_flash_upload_prompt'
components={{
catbox: <a href='https://catbox.moe/' target='_blank' rel='noopener noreferrer' aria-label='Catbox' />,
}}
/>
</li>
)}
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
</ul>
</td>
@@ -467,6 +506,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const subjectRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const flagRef = useRef<HTMLSelectElement>(null);
const flashTagRef = useRef<HTMLSelectElement>(null);
const fortuneEntryRef = useRef<FortuneEntry | null>(null);
const diceRollRef = useRef<DiceRoll | null>(null);
const nonokoRedirectPathRef = useRef<string | null>(null);
@@ -488,6 +528,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
@@ -536,6 +578,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
if (flagRef.current) {
flagRef.current.value = flagRef.current.options[0]?.value ?? '';
}
if (flashTagRef.current) {
flashTagRef.current.value = '';
}
checkContentLength.cancel();
checkPostOptions.cancel();
fortuneEntryRef.current = null;
@@ -597,9 +642,15 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
const publishOptions = {
...flagPublishOptions,
...(flairs ? { flairs } : {}),
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...flagPublishOptions });
publishPost({ content: publishContent, ...publishOptions });
};
// redirect to pending page when pending comment is created
@@ -785,6 +836,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
subjectRef={subjectRef}
optionsRef={optionsRef}
flagRef={flagRef}
flashTagRef={flashTagRef}
textRef={textRef}
urlRef={urlRef}
url={url}
@@ -811,6 +863,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
rulesPath={rulesPath}
requirePostLinkIsMedia={requirePostLinkIsMedia}
flagOptions={flagOptions}
flashTagOptions={FLASH_TAG_OPTIONS}
showFlashTagSelector={showFlashTagSelector}
showFlashUploadPrompt={showFlashUploadPrompt}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
@@ -29,6 +29,7 @@ import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis';
import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag';
import PostMenuMobile from './post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
@@ -351,6 +352,7 @@ const PostInfoAndMedia = ({
</>
)}
<PostAuthorFlags author={author} comment={resolvedPost} enabled={showAuthorFlags} />
<PostFlashTag comment={resolvedPost} directory={directoryEntry} />
{pinned && (
<span className={styles.stickyIconWrapper}>
<img src='assets/icons/sticky.gif' alt='' className={styles.stickyIcon} title={t('sticky')} />