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;