mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
support flash
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "plspace",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ruffle-rs/ruffle": "^0.4.0-nightly.2026.7.7",
|
||||
"dompurify": "^3.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -723,6 +724,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ruffle-rs/ruffle": {
|
||||
"version": "0.4.0-nightly.2026.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@ruffle-rs/ruffle/-/ruffle-0.4.0-nightly.2026.7.7.tgz",
|
||||
"integrity": "sha512-VrTxTCYWRaArk4gMi4EAIGAH36AlWphQNiIlwvj5DGjurXxRvl4tcm4QKwA4b3DekYVtOMQPoVZBuENGAQsNwg==",
|
||||
"license": "(MIT OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ruffle-rs/ruffle": "^0.4.0-nightly.2026.7.7",
|
||||
"dompurify": "^3.4.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,20 @@
|
||||
* can mix flagged and unflagged media.
|
||||
*/
|
||||
import type { CustomEmoji, MediaAttachment } from '$lib/api/types'
|
||||
import type { RuffleLoader } from '$lib/ruffle'
|
||||
import { isFlashAttachment } from '$lib/util/flash'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
import FlashAttachment from './FlashAttachment.svelte'
|
||||
|
||||
interface Props {
|
||||
attachments: MediaAttachment[]
|
||||
emojis?: CustomEmoji[]
|
||||
sensitive?: boolean
|
||||
/** Injectable so Flash rendering remains backend- and network-free in tests. */
|
||||
loadRuffle?: RuffleLoader
|
||||
}
|
||||
|
||||
let { attachments, emojis, sensitive = false }: Props = $props()
|
||||
let { attachments, emojis, sensitive = false, loadRuffle }: Props = $props()
|
||||
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
@@ -49,7 +54,15 @@
|
||||
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
|
||||
>
|
||||
<figure class="attachment-figure">
|
||||
{#if media.type === 'video' || media.type === 'gifv'}
|
||||
{#if isFlashAttachment(media)}
|
||||
{#if isRevealed(media.id)}
|
||||
<FlashAttachment {media} {loadRuffle} />
|
||||
{:else}
|
||||
<div class="attachment-media flash-sensitive-placeholder">
|
||||
Sensitive Flash attachment
|
||||
</div>
|
||||
{/if}
|
||||
{:else if media.type === 'video' || media.type === 'gifv'}
|
||||
<video
|
||||
class="attachment-media"
|
||||
src={videoPreviewUrl(media.url)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { fireEvent, render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
import Attachments from './Attachments.svelte'
|
||||
@@ -40,3 +40,36 @@ describe('Attachments raw-video previews', () => {
|
||||
expect(element).toHaveAttribute('loop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attachments Flash support', () => {
|
||||
const flash: MediaAttachment = {
|
||||
id: 'flash-1',
|
||||
type: 'unknown',
|
||||
url: 'https://media.example.test/movie.swf?download=1',
|
||||
preview_url: null,
|
||||
pleroma: { mime_type: 'application/x-shockwave-flash' },
|
||||
}
|
||||
|
||||
it('offers unknown SWF attachments through the lazy Ruffle player', () => {
|
||||
const loadRuffle = async () => {
|
||||
throw new Error('should remain inert')
|
||||
}
|
||||
const view = render(Attachments, { attachments: [flash], loadRuffle })
|
||||
|
||||
expect(view.getByRole('button', { name: /Play Flash attachment/i })).toBeInTheDocument()
|
||||
expect(view.getByRole('link', { name: 'Download original SWF' })).toHaveAttribute(
|
||||
'href',
|
||||
flash.url,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not expose a player control until sensitive Flash is revealed', async () => {
|
||||
const view = render(Attachments, { attachments: [flash], sensitive: true })
|
||||
|
||||
expect(view.queryByRole('button', { name: /Play Flash attachment/i })).not.toBeInTheDocument()
|
||||
expect(view.getByText('Sensitive Flash attachment')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Show sensitive media' }))
|
||||
expect(view.getByRole('button', { name: /Play Flash attachment/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,18 @@
|
||||
import { untrack } from 'svelte'
|
||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { isFlashAttachment } from '$lib/util/flash'
|
||||
import QuoteCard from './QuoteCard.svelte'
|
||||
|
||||
const MEDIA_ACCEPT = [
|
||||
'image/*',
|
||||
'video/*',
|
||||
'audio/*',
|
||||
'.swf',
|
||||
'application/x-shockwave-flash',
|
||||
'application/vnd.adobe.flash.movie',
|
||||
].join(',')
|
||||
|
||||
interface Props {
|
||||
/** Set to reply to an existing entry. */
|
||||
inReplyTo?: Status | null
|
||||
@@ -310,7 +320,19 @@
|
||||
<ul class="composer-attachments">
|
||||
{#each attachments as media (media.id)}
|
||||
<li class="composer-attachment">
|
||||
{#if media.type === 'image' || ((media.type === 'video' || media.type === 'gifv') && media.preview_url)}
|
||||
<img src={media.preview_url ?? media.url} alt={media.description ?? ''} />
|
||||
{:else}
|
||||
<span class="composer-attachment-preview">
|
||||
{isFlashAttachment(media)
|
||||
? 'Flash (.swf)'
|
||||
: media.type === 'audio'
|
||||
? 'Audio'
|
||||
: media.type === 'video' || media.type === 'gifv'
|
||||
? 'Video'
|
||||
: 'Attachment'}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
@@ -382,11 +404,11 @@
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<label class="button button--small composer-upload">
|
||||
{uploading ? 'Uploading…' : 'Add photo'}
|
||||
{uploading ? 'Uploading…' : 'Add media'}
|
||||
<input
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
accept={MEDIA_ACCEPT}
|
||||
multiple
|
||||
disabled={uploading || showPoll || attachments.length >= maxAttachments}
|
||||
onchange={onFiles}
|
||||
|
||||
@@ -183,6 +183,41 @@ describe('Composer', () => {
|
||||
expect(postStatus.mock.calls[0][1].media_ids).toEqual(['pasted-media'])
|
||||
})
|
||||
|
||||
it('offers common media and SWF files and uploads an SWF unchanged', async () => {
|
||||
const swf = new File(['flash bytes'], 'animation.swf', {
|
||||
type: 'application/x-shockwave-flash',
|
||||
})
|
||||
const uploadMedia = vi.fn().mockResolvedValue({
|
||||
id: 'flash-media',
|
||||
type: 'unknown',
|
||||
url: 'https://media.example/animation.swf',
|
||||
preview_url: null,
|
||||
description: null,
|
||||
pleroma: { mime_type: 'application/x-shockwave-flash' },
|
||||
})
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { uploadMedia },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
const picker = view.container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
|
||||
expect(view.getByText('Add media')).toBeInTheDocument()
|
||||
expect(picker?.accept).toContain('image/*')
|
||||
expect(picker?.accept).toContain('video/*')
|
||||
expect(picker?.accept).toContain('.swf')
|
||||
expect(picker?.accept).toContain('application/x-shockwave-flash')
|
||||
|
||||
await fireEvent.change(picker!, { target: { files: [swf] } })
|
||||
await waitFor(() => expect(uploadMedia).toHaveBeenCalledOnce())
|
||||
|
||||
expect(uploadMedia.mock.calls[0][1]).toBe(swf)
|
||||
expect(view.getByText('Flash (.swf)')).toBeInTheDocument()
|
||||
expect(view.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves ordinary text-only paste alone', async () => {
|
||||
const uploadMedia = vi.fn()
|
||||
const services = testServices({
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
import {
|
||||
loadRuffle as defaultLoadRuffle,
|
||||
type RuffleLoader,
|
||||
type RufflePlayerElement,
|
||||
} from '$lib/ruffle'
|
||||
import { flashAspectRatio } from '$lib/util/flash'
|
||||
|
||||
interface Props {
|
||||
media: MediaAttachment
|
||||
loadRuffle?: RuffleLoader
|
||||
}
|
||||
|
||||
let { media, loadRuffle = defaultLoadRuffle }: Props = $props()
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let player: RufflePlayerElement | null = null
|
||||
let playerState = $state<'idle' | 'loading' | 'playing' | 'error'>('idle')
|
||||
let generation = 0
|
||||
const aspectRatio = $derived(flashAspectRatio(media))
|
||||
|
||||
async function play(): Promise<void> {
|
||||
if (playerState === 'loading' || playerState === 'playing') return
|
||||
const currentGeneration = ++generation
|
||||
playerState = 'loading'
|
||||
|
||||
try {
|
||||
const ruffle = await loadRuffle()
|
||||
if (currentGeneration !== generation || !container) return
|
||||
|
||||
const next = ruffle.newest().createPlayer()
|
||||
next.className = 'flash-player'
|
||||
next.style.width = '100%'
|
||||
next.style.height = '100%'
|
||||
next.config = {
|
||||
letterbox: 'on',
|
||||
allowScriptAccess: false,
|
||||
allowNetworking: 'internal',
|
||||
openUrlMode: 'confirm',
|
||||
}
|
||||
container.replaceChildren(next)
|
||||
player = next
|
||||
await next.ruffle().load({
|
||||
url: media.url,
|
||||
autoplay: 'on',
|
||||
letterbox: 'on',
|
||||
allowScriptAccess: false,
|
||||
allowNetworking: 'internal',
|
||||
openUrlMode: 'confirm',
|
||||
})
|
||||
if (currentGeneration === generation) playerState = 'playing'
|
||||
} catch {
|
||||
if (currentGeneration === generation) {
|
||||
player?.remove()
|
||||
player = null
|
||||
playerState = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
generation += 1
|
||||
player?.remove()
|
||||
player = null
|
||||
container?.replaceChildren()
|
||||
playerState = 'idle'
|
||||
}
|
||||
|
||||
onDestroy(stop)
|
||||
</script>
|
||||
|
||||
<div class="flash-attachment" data-state={playerState} style={`--flash-aspect-ratio: ${aspectRatio}`}>
|
||||
<div class="flash-player-container" bind:this={container} hidden={playerState !== 'playing'}></div>
|
||||
|
||||
{#if playerState !== 'playing'}
|
||||
<button
|
||||
type="button"
|
||||
class="flash-placeholder"
|
||||
disabled={playerState === 'loading'}
|
||||
onclick={() => void play()}
|
||||
>
|
||||
{#if playerState === 'loading'}
|
||||
<strong>Loading Flash…</strong>
|
||||
{:else if playerState === 'error'}
|
||||
<strong>Flash content could not be loaded. Click to retry.</strong>
|
||||
{:else}
|
||||
<strong>Play Flash attachment with Ruffle</strong>
|
||||
<span>Experimental: Flash content is arbitrary code. Only play attachments you trust.</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class="button button--small flash-stop" onclick={stop}>
|
||||
Stop Flash player
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<a class="flash-download" href={media.url} target="_blank" rel="noopener noreferrer">
|
||||
Download original SWF
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
import type { RufflePlayerElement } from '$lib/ruffle'
|
||||
import FlashAttachment from './FlashAttachment.svelte'
|
||||
|
||||
const media: MediaAttachment = {
|
||||
id: 'flash-1',
|
||||
type: 'unknown',
|
||||
url: 'https://media.example.test/movie.swf',
|
||||
preview_url: null,
|
||||
description: 'A Flash movie',
|
||||
meta: { original: { width: 640, height: 480 } },
|
||||
}
|
||||
|
||||
describe('FlashAttachment', () => {
|
||||
it('stays inert until clicked, loads securely through Ruffle, and can be stopped', async () => {
|
||||
const load = vi.fn().mockResolvedValue(undefined)
|
||||
const player = document.createElement('ruffle-player') as RufflePlayerElement
|
||||
player.config = {}
|
||||
player.ruffle = () => ({ load })
|
||||
const createPlayer = vi.fn(() => player)
|
||||
const loadRuffle = vi.fn().mockResolvedValue({
|
||||
newest: () => ({ createPlayer }),
|
||||
})
|
||||
const view = render(FlashAttachment, { media, loadRuffle })
|
||||
|
||||
expect(loadRuffle).not.toHaveBeenCalled()
|
||||
expect(view.getByText(/arbitrary code/i)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: /Play Flash attachment/i }))
|
||||
await waitFor(() => expect(load).toHaveBeenCalled())
|
||||
|
||||
expect(createPlayer).toHaveBeenCalledOnce()
|
||||
expect(player.config).toMatchObject({
|
||||
allowScriptAccess: false,
|
||||
allowNetworking: 'internal',
|
||||
openUrlMode: 'confirm',
|
||||
})
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
url: media.url,
|
||||
autoplay: 'on',
|
||||
letterbox: 'on',
|
||||
allowScriptAccess: false,
|
||||
allowNetworking: 'internal',
|
||||
openUrlMode: 'confirm',
|
||||
})
|
||||
expect(view.container.querySelector('ruffle-player')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Stop Flash player' }))
|
||||
expect(view.container.querySelector('ruffle-player')).not.toBeInTheDocument()
|
||||
expect(view.getByRole('button', { name: /Play Flash attachment/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retry action after the runtime fails', async () => {
|
||||
const loadRuffle = vi.fn().mockRejectedValue(new Error('no wasm'))
|
||||
const view = render(FlashAttachment, { media, loadRuffle })
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: /Play Flash attachment/i }))
|
||||
expect(
|
||||
await view.findByRole('button', { name: /could not be loaded.*retry/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fetchQuotes,
|
||||
postStatus,
|
||||
setEmojiReaction,
|
||||
uploadMedia,
|
||||
updateProfileFields,
|
||||
updatePublicProfile,
|
||||
votePoll,
|
||||
@@ -97,6 +98,39 @@ describe('updatePublicProfile', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('media uploads', () => {
|
||||
it('preserves an SWF file and its MIME type in the multipart upload', async () => {
|
||||
const swf = new File(['flash bytes'], 'animation.swf', {
|
||||
type: 'application/x-shockwave-flash',
|
||||
})
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const form = init?.body as FormData
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(form.get('file')).toBe(swf)
|
||||
expect((form.get('file') as File).name).toBe('animation.swf')
|
||||
expect((form.get('file') as File).type).toBe('application/x-shockwave-flash')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'flash-1',
|
||||
type: 'unknown',
|
||||
url: 'https://media.example.test/animation.swf',
|
||||
preview_url: null,
|
||||
pleroma: { mime_type: 'application/x-shockwave-flash' },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await uploadMedia(new ApiClient('example.test', 'token'), swf)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/media',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('poll endpoints', () => {
|
||||
it('submits selected poll option indexes as JSON', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
|
||||
@@ -110,7 +110,7 @@ export interface CredentialAccount extends Account {
|
||||
|
||||
export interface MediaAttachment {
|
||||
id: string
|
||||
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
|
||||
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' | 'flash'
|
||||
url: string
|
||||
preview_url: string | null
|
||||
remote_url?: string | null
|
||||
@@ -121,6 +121,12 @@ export interface MediaAttachment {
|
||||
small?: { width?: number; height?: number; aspect?: number }
|
||||
[key: string]: unknown
|
||||
}
|
||||
/** Pleroma/Akkoma preserve the original attachment MIME type here. */
|
||||
pleroma?: {
|
||||
mime_type?: string | null
|
||||
name?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface StatusMention {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export interface RufflePlayerApi {
|
||||
load(options: RuffleLoadOptions | string): Promise<void>
|
||||
}
|
||||
|
||||
export interface RuffleLoadOptions {
|
||||
url: string
|
||||
autoplay?: 'on' | 'off' | 'auto'
|
||||
letterbox?: 'on' | 'off' | 'fullscreen'
|
||||
allowScriptAccess?: boolean
|
||||
allowNetworking?: 'all' | 'internal' | 'none'
|
||||
openUrlMode?: 'allow' | 'confirm' | 'deny'
|
||||
}
|
||||
|
||||
export interface RufflePlayerElement extends HTMLElement {
|
||||
config: Partial<RuffleLoadOptions>
|
||||
ruffle(version?: 1): RufflePlayerApi
|
||||
}
|
||||
|
||||
export interface RuffleSource {
|
||||
createPlayer(): RufflePlayerElement
|
||||
}
|
||||
|
||||
export interface RufflePublicApi {
|
||||
config?: Record<string, unknown>
|
||||
newest(): RuffleSource
|
||||
}
|
||||
|
||||
export type RuffleLoader = () => Promise<RufflePublicApi>
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
RufflePlayer?: Partial<RufflePublicApi>
|
||||
}
|
||||
}
|
||||
|
||||
let loading: Promise<RufflePublicApi> | null = null
|
||||
|
||||
function installedRuffle(): RufflePublicApi | null {
|
||||
return typeof window.RufflePlayer?.newest === 'function'
|
||||
? (window.RufflePlayer as RufflePublicApi)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Lazy-load the bundled self-hosted runtime once for every Flash attachment. */
|
||||
export const loadRuffle: RuffleLoader = async () => {
|
||||
const installed = installedRuffle()
|
||||
if (installed) return installed
|
||||
if (loading) return loading
|
||||
|
||||
loading = new Promise<RufflePublicApi>((resolve, reject) => {
|
||||
const publicPath = new URL(`${import.meta.env.BASE_URL}ruffle/`, document.baseURI).href
|
||||
window.RufflePlayer = {
|
||||
...(window.RufflePlayer ?? {}),
|
||||
config: {
|
||||
...(window.RufflePlayer?.config ?? {}),
|
||||
polyfills: false,
|
||||
publicPath,
|
||||
},
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = new URL('ruffle.js', publicPath).href
|
||||
script.async = true
|
||||
script.dataset.plspaceRuffle = 'true'
|
||||
script.onload = () => {
|
||||
const api = installedRuffle()
|
||||
if (api) resolve(api)
|
||||
else reject(new Error('Ruffle loaded without installing its player API.'))
|
||||
}
|
||||
script.onerror = () => reject(new Error('Could not load the bundled Ruffle runtime.'))
|
||||
document.head.appendChild(script)
|
||||
}).catch((cause) => {
|
||||
loading = null
|
||||
throw cause
|
||||
})
|
||||
|
||||
return loading
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
import { flashAspectRatio, isFlashAttachment } from './flash'
|
||||
|
||||
function attachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
||||
return {
|
||||
id: 'file-1',
|
||||
type: 'unknown',
|
||||
url: 'https://media.example.test/file.bin',
|
||||
preview_url: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Flash attachment detection', () => {
|
||||
it('recognizes Pleroma MIME metadata and explicit Flash types', () => {
|
||||
expect(
|
||||
isFlashAttachment(
|
||||
attachment({ pleroma: { mime_type: 'application/x-shockwave-flash' } }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(isFlashAttachment(attachment({ type: 'flash' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes case-insensitive SWF paths despite query strings or fragments', () => {
|
||||
expect(
|
||||
isFlashAttachment(
|
||||
attachment({ url: 'https://media.example.test/games/MOVIE.SWF?download=1#play' }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isFlashAttachment(attachment({ url: 'https://media.example.test/movie.swf.png' })),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('uses bounded intrinsic dimensions and a safe fallback', () => {
|
||||
expect(
|
||||
flashAspectRatio(
|
||||
attachment({ meta: { original: { width: 1920, height: 1080 } } }),
|
||||
),
|
||||
).toBeCloseTo(16 / 9)
|
||||
expect(
|
||||
flashAspectRatio(attachment({ meta: { original: { width: 10000, height: 1 } } })),
|
||||
).toBeCloseTo(4 / 3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MediaAttachment } from '../api/types'
|
||||
|
||||
/** Detect Pleroma's Flash MIME extension and Mastodon-compatible `.swf` URLs. */
|
||||
export function isFlashAttachment(media: MediaAttachment): boolean {
|
||||
if (media.type === 'flash') return true
|
||||
if (/flash/i.test(media.pleroma?.mime_type ?? '')) return true
|
||||
|
||||
for (const value of [media.url, media.remote_url]) {
|
||||
if (!value) continue
|
||||
try {
|
||||
if (/\.swf$/i.test(new URL(value, window.location.href).pathname)) return true
|
||||
} catch {
|
||||
if (/\.swf(?:[?#]|$)/i.test(value)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Use trustworthy server dimensions, while preventing pathological layouts. */
|
||||
export function flashAspectRatio(media: MediaAttachment): number {
|
||||
const width = media.meta?.original?.width
|
||||
const height = media.meta?.original?.height
|
||||
const ratio = width && height ? width / height : Number.NaN
|
||||
return Number.isFinite(ratio) && ratio >= 0.25 && ratio <= 4 ? ratio : 4 / 3
|
||||
}
|
||||
@@ -148,6 +148,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attachment-figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachment-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -177,6 +181,68 @@
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
/* Flash remains inert until explicitly started with the bundled Ruffle player. */
|
||||
.flash-attachment {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--flash-aspect-ratio, 4 / 3);
|
||||
min-height: 180px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.flash-player-container,
|
||||
.flash-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.flash-player-container[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flash-placeholder {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
color: var(--ms-link);
|
||||
background: var(--ms-table-stripe-bg);
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash-placeholder span {
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
.flash-stop {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.flash-download {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 4px;
|
||||
z-index: 2;
|
||||
padding: 2px 4px;
|
||||
background: var(--ms-page-bg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
.flash-sensitive-placeholder {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* YouTube links become privacy-enhanced players in the attachment space. */
|
||||
.youtube-attachment-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||
|
||||
+16
-1
@@ -176,13 +176,28 @@ textarea {
|
||||
border: 1px solid var(--ms-avatar-border);
|
||||
}
|
||||
|
||||
.composer-attachment img {
|
||||
.composer-attachment img,
|
||||
.composer-attachment-preview {
|
||||
display: block;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
}
|
||||
|
||||
.composer-attachment img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 4px;
|
||||
background: var(--ms-table-stripe-bg);
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.composer-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
+45
-1
@@ -2,12 +2,56 @@ import { defineConfig } from 'vitest/config'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { svelteTesting } from '@testing-library/svelte/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const RUFFLE_DIRECTORY = fileURLToPath(
|
||||
new URL('./node_modules/@ruffle-rs/ruffle/', import.meta.url),
|
||||
)
|
||||
const RUFFLE_ASSET_PATTERN = /^(?:ruffle\.js|core\.ruffle\..+\.js|.+\.wasm|LICENSE_(?:MIT|APACHE))$/
|
||||
|
||||
/** Serve and emit the pinned self-hosted runtime without a third-party CDN. */
|
||||
function ruffleAssets(): Plugin {
|
||||
const files = readdirSync(RUFFLE_DIRECTORY).filter((name) => RUFFLE_ASSET_PATTERN.test(name))
|
||||
let building = false
|
||||
|
||||
return {
|
||||
name: 'plspace-ruffle-assets',
|
||||
configResolved(config) {
|
||||
building = config.command === 'build'
|
||||
},
|
||||
buildStart() {
|
||||
if (!building) return
|
||||
for (const name of files) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `ruffle/${name}`,
|
||||
source: readFileSync(`${RUFFLE_DIRECTORY}/${name}`),
|
||||
})
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/ruffle', (request, response, next) => {
|
||||
const name = decodeURIComponent((request.url ?? '').replace(/^\//, '').split('?')[0])
|
||||
if (!files.includes(name)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
name.endsWith('.wasm') ? 'application/wasm' : name.endsWith('.js') ? 'text/javascript' : 'text/plain',
|
||||
)
|
||||
response.end(readFileSync(`${RUFFLE_DIRECTORY}/${name}`))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fully static output: the app talks to a Mastodon/Pleroma server directly from
|
||||
// the browser, so `dist/` can be dropped on any static host (or file://-ish CDN).
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [svelte(), svelteTesting()],
|
||||
plugins: [svelte(), svelteTesting(), ruffleAssets()],
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user