mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
Compare commits
3
Commits
1b14d94f6a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c76bab55dd | ||
|
|
80c6134359 | ||
|
|
7d16969f02 |
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "plspace",
|
"name": "plspace",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ruffle-rs/ruffle": "^0.4.0-nightly.2026.7.7",
|
||||||
"dompurify": "^3.4.12"
|
"dompurify": "^3.4.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -723,6 +724,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ruffle-rs/ruffle": "^0.4.0-nightly.2026.7.7",
|
||||||
"dompurify": "^3.4.12"
|
"dompurify": "^3.4.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,20 @@
|
|||||||
* can mix flagged and unflagged media.
|
* can mix flagged and unflagged media.
|
||||||
*/
|
*/
|
||||||
import type { CustomEmoji, MediaAttachment } from '$lib/api/types'
|
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 EmojiText from '../common/EmojiText.svelte'
|
||||||
|
import FlashAttachment from './FlashAttachment.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachments: MediaAttachment[]
|
attachments: MediaAttachment[]
|
||||||
emojis?: CustomEmoji[]
|
emojis?: CustomEmoji[]
|
||||||
sensitive?: boolean
|
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>>({})
|
let revealed = $state<Record<string, boolean>>({})
|
||||||
|
|
||||||
@@ -49,7 +54,15 @@
|
|||||||
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
|
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
<figure class="attachment-figure">
|
<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
|
<video
|
||||||
class="attachment-media"
|
class="attachment-media"
|
||||||
src={videoPreviewUrl(media.url)}
|
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 { describe, expect, it } from 'vitest'
|
||||||
import type { MediaAttachment } from '$lib/api/types'
|
import type { MediaAttachment } from '$lib/api/types'
|
||||||
import Attachments from './Attachments.svelte'
|
import Attachments from './Attachments.svelte'
|
||||||
@@ -40,3 +40,36 @@ describe('Attachments raw-video previews', () => {
|
|||||||
expect(element).toHaveAttribute('loop')
|
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 { untrack } from 'svelte'
|
||||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
|
import { isFlashAttachment } from '$lib/util/flash'
|
||||||
import QuoteCard from './QuoteCard.svelte'
|
import QuoteCard from './QuoteCard.svelte'
|
||||||
|
|
||||||
|
const MEDIA_ACCEPT = [
|
||||||
|
'image/*',
|
||||||
|
'video/*',
|
||||||
|
'audio/*',
|
||||||
|
'.swf',
|
||||||
|
'application/x-shockwave-flash',
|
||||||
|
'application/vnd.adobe.flash.movie',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Set to reply to an existing entry. */
|
/** Set to reply to an existing entry. */
|
||||||
inReplyTo?: Status | null
|
inReplyTo?: Status | null
|
||||||
@@ -310,7 +320,19 @@
|
|||||||
<ul class="composer-attachments">
|
<ul class="composer-attachments">
|
||||||
{#each attachments as media (media.id)}
|
{#each attachments as media (media.id)}
|
||||||
<li class="composer-attachment">
|
<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 ?? ''} />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button button--small"
|
class="button button--small"
|
||||||
@@ -382,11 +404,11 @@
|
|||||||
|
|
||||||
<div class="composer-toolbar">
|
<div class="composer-toolbar">
|
||||||
<label class="button button--small composer-upload">
|
<label class="button button--small composer-upload">
|
||||||
{uploading ? 'Uploading…' : 'Add photo'}
|
{uploading ? 'Uploading…' : 'Add media'}
|
||||||
<input
|
<input
|
||||||
class="visually-hidden"
|
class="visually-hidden"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*,audio/*"
|
accept={MEDIA_ACCEPT}
|
||||||
multiple
|
multiple
|
||||||
disabled={uploading || showPoll || attachments.length >= maxAttachments}
|
disabled={uploading || showPoll || attachments.length >= maxAttachments}
|
||||||
onchange={onFiles}
|
onchange={onFiles}
|
||||||
|
|||||||
@@ -183,6 +183,41 @@ describe('Composer', () => {
|
|||||||
expect(postStatus.mock.calls[0][1].media_ids).toEqual(['pasted-media'])
|
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 () => {
|
it('leaves ordinary text-only paste alone', async () => {
|
||||||
const uploadMedia = vi.fn()
|
const uploadMedia = vi.fn()
|
||||||
const services = testServices({
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -74,9 +74,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="site-account-links">
|
<p class="site-account-links">
|
||||||
<a href="#/settings">Settings</a>
|
|
||||||
{#if session.signedIn}
|
{#if session.signedIn}
|
||||||
<span aria-hidden="true">|</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="link-button site-header-logout"
|
class="link-button site-header-logout"
|
||||||
@@ -85,7 +83,6 @@
|
|||||||
LogOut
|
LogOut
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span aria-hidden="true">|</span>
|
|
||||||
<a href="#/login">LogIn</a>
|
<a href="#/login">LogIn</a>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { render } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
|
import { account, session, testServices } from '$test/fixtures'
|
||||||
|
import SiteHeader from './SiteHeader.svelte'
|
||||||
|
|
||||||
|
describe('SiteHeader account controls', () => {
|
||||||
|
it('shows one visible logout control and leaves Settings to the main navigation', () => {
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ signedIn: true, token: 'token', me: account() }),
|
||||||
|
})
|
||||||
|
const view = render(SiteHeader, {
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.getByRole('button', { name: 'LogOut' })).toHaveClass('site-header-logout')
|
||||||
|
expect(view.queryByRole('link', { name: 'Settings' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,11 +4,19 @@
|
|||||||
* in a separate settings editor.
|
* in a separate settings editor.
|
||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { CredentialAccount } from '$lib/api/types'
|
import type { Account, CredentialAccount } from '$lib/api/types'
|
||||||
import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities'
|
import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities'
|
||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { profileFieldLimits } from '$lib/stores/theme.svelte'
|
import { profileFieldLimits } from '$lib/stores/theme.svelte'
|
||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
|
import { fullHandle } from '$lib/util/profile'
|
||||||
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
|
import {
|
||||||
|
parseTopEightText,
|
||||||
|
profileBioLimit,
|
||||||
|
TOP_EIGHT_MAX,
|
||||||
|
withTopEight,
|
||||||
|
} from '$lib/util/top-eight'
|
||||||
|
|
||||||
interface EditableField {
|
interface EditableField {
|
||||||
id: number
|
id: number
|
||||||
@@ -40,8 +48,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initial = untrack(() => session.me)
|
const initial = untrack(() => session.me)
|
||||||
|
const initialBio = parseTopEightText(initial?.source?.note ?? toPlainText(initial?.note ?? ''))
|
||||||
let displayName = $state(initial?.display_name ?? '')
|
let displayName = $state(initial?.display_name ?? '')
|
||||||
let note = $state(initial?.source?.note ?? toPlainText(initial?.note ?? ''))
|
let note = $state(initialBio.bio)
|
||||||
|
let topEightHandles = $state<string[]>(initialBio.handles)
|
||||||
|
let topEightQuery = $state('')
|
||||||
|
let topEightResults = $state<Account[]>([])
|
||||||
|
let topEightSearching = $state(false)
|
||||||
|
let topEightSearchError = $state<string | null>(null)
|
||||||
let fields = $state<EditableField[]>(publicFields(initial))
|
let fields = $state<EditableField[]>(publicFields(initial))
|
||||||
let actorType = $state<'Person' | 'Service' | 'Group'>(
|
let actorType = $state<'Person' | 'Service' | 'Group'>(
|
||||||
initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'),
|
initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'),
|
||||||
@@ -69,6 +83,12 @@
|
|||||||
const isEgregorosServer = $derived(isEgregoros(session.instance))
|
const isEgregorosServer = $derived(isEgregoros(session.instance))
|
||||||
const capabilities = $derived(publicProfileCapabilities(session.instance))
|
const capabilities = $derived(publicProfileCapabilities(session.instance))
|
||||||
const limits = $derived(profileFieldLimits(session.instance))
|
const limits = $derived(profileFieldLimits(session.instance))
|
||||||
|
const bioLimit = $derived(profileBioLimit(session.instance))
|
||||||
|
const savedNote = $derived(withTopEight(note, topEightHandles))
|
||||||
|
const bioCharactersLeft = $derived(bioLimit.value - savedNote.length)
|
||||||
|
const topEightAvailable = $derived(
|
||||||
|
topEightHandles.length > 0 || bioLimit.value - note.length >= 30,
|
||||||
|
)
|
||||||
const reservedFields = $derived(internalFields(session.me).length)
|
const reservedFields = $derived(internalFields(session.me).length)
|
||||||
const availablePublicFields = $derived(
|
const availablePublicFields = $derived(
|
||||||
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
|
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
|
||||||
@@ -82,7 +102,12 @@
|
|||||||
field.name.length <= limits.nameLength && field.value.length <= limits.valueLength,
|
field.name.length <= limits.nameLength && field.value.length <= limits.valueLength,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy)
|
const canSave = $derived(
|
||||||
|
Boolean(displayName.trim()) &&
|
||||||
|
fieldsValid &&
|
||||||
|
(bioLimit.estimated || bioCharactersLeft >= 0) &&
|
||||||
|
!busy,
|
||||||
|
)
|
||||||
|
|
||||||
function addField(): void {
|
function addField(): void {
|
||||||
if (!canAddField) return
|
if (!canAddField) return
|
||||||
@@ -93,6 +118,49 @@
|
|||||||
fields = fields.filter((field) => field.id !== id)
|
fields = fields.filter((field) => field.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchTopEight(): Promise<void> {
|
||||||
|
const query = topEightQuery.trim()
|
||||||
|
if (!query || topEightSearching) return
|
||||||
|
topEightSearching = true
|
||||||
|
topEightSearchError = null
|
||||||
|
try {
|
||||||
|
const found = await endpoints.search(session.api, query, { type: 'accounts', limit: 5 })
|
||||||
|
topEightResults = found.accounts.filter(
|
||||||
|
(candidate) => !topEightHandles.some(
|
||||||
|
(handle) => handle.toLowerCase() === fullHandle(candidate, instanceDomain(session.instance, session.host)).toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (topEightResults.length === 0) topEightSearchError = 'No matching people found.'
|
||||||
|
} catch (cause) {
|
||||||
|
topEightSearchError = cause instanceof Error ? cause.message : 'Could not search for that person.'
|
||||||
|
} finally {
|
||||||
|
topEightSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTopEight(candidate: Account): void {
|
||||||
|
if (topEightHandles.length >= TOP_EIGHT_MAX) return
|
||||||
|
const handle = fullHandle(candidate, instanceDomain(session.instance, session.host))
|
||||||
|
if (!topEightHandles.some((item) => item.toLowerCase() === handle.toLowerCase())) {
|
||||||
|
topEightHandles = [...topEightHandles, handle]
|
||||||
|
}
|
||||||
|
topEightQuery = ''
|
||||||
|
topEightResults = []
|
||||||
|
topEightSearchError = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTopEight(index: number): void {
|
||||||
|
topEightHandles = topEightHandles.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTopEight(index: number, direction: -1 | 1): void {
|
||||||
|
const destination = index + direction
|
||||||
|
if (destination < 0 || destination >= topEightHandles.length) return
|
||||||
|
const reordered = [...topEightHandles]
|
||||||
|
;[reordered[index], reordered[destination]] = [reordered[destination], reordered[index]]
|
||||||
|
topEightHandles = reordered
|
||||||
|
}
|
||||||
|
|
||||||
function chooseImage(
|
function chooseImage(
|
||||||
kind: 'avatar' | 'header' | 'background',
|
kind: 'avatar' | 'header' | 'background',
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -118,8 +186,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetFrom(account: CredentialAccount): void {
|
function resetFrom(account: CredentialAccount): void {
|
||||||
|
const parsedBio = parseTopEightText(account.source?.note ?? toPlainText(account.note))
|
||||||
displayName = account.display_name
|
displayName = account.display_name
|
||||||
note = account.source?.note ?? toPlainText(account.note)
|
note = parsedBio.bio
|
||||||
|
topEightHandles = parsedBio.handles
|
||||||
|
topEightQuery = ''
|
||||||
|
topEightResults = []
|
||||||
fields = publicFields(account)
|
fields = publicFields(account)
|
||||||
actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person')
|
actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person')
|
||||||
birthday = account.pleroma?.birthday ?? ''
|
birthday = account.pleroma?.birthday ?? ''
|
||||||
@@ -154,7 +226,7 @@
|
|||||||
: []
|
: []
|
||||||
const updated = await endpoints.updatePublicProfile(session.api, {
|
const updated = await endpoints.updatePublicProfile(session.api, {
|
||||||
displayName: displayName.trim(),
|
displayName: displayName.trim(),
|
||||||
note,
|
note: savedNote,
|
||||||
fields: capabilities.fields ? [...visible, ...hidden] : undefined,
|
fields: capabilities.fields ? [...visible, ...hidden] : undefined,
|
||||||
avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
|
avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
|
||||||
header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
|
header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
|
||||||
@@ -208,9 +280,84 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="profile-bio">About me / bio</label>
|
<label class="field-label" for="profile-bio">About me / bio</label>
|
||||||
<textarea id="profile-bio" class="field-input profile-editor-bio" bind:value={note} rows="7"></textarea>
|
<textarea id="profile-bio" class="field-input profile-editor-bio" bind:value={note} rows="7"></textarea>
|
||||||
<p class="field-hint">Your server may support plain text, Markdown or other formatting here.</p>
|
<p class="field-hint">
|
||||||
|
Your server may support plain text, Markdown or other formatting here.
|
||||||
|
{savedNote.length.toLocaleString()} of about {bioLimit.value.toLocaleString()} characters used{bioLimit.estimated ? ' (estimated)' : ''}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if topEightAvailable}
|
||||||
|
<fieldset class="profile-editor-top-eight">
|
||||||
|
<legend>My Top 8</legend>
|
||||||
|
<p class="field-hint">
|
||||||
|
plspace stores this as a readable <code>My top 8:</code> section in your public bio.
|
||||||
|
Other clients will see the list as text; plspace visitors get the full picture grid.
|
||||||
|
Saving this form preserves your published CSS fields.
|
||||||
|
{#if reservedFields > 0}
|
||||||
|
Your plspace CSS currently uses {reservedFields} of {limits.maxFields} profile-field slots,
|
||||||
|
but it does not consume bio characters.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if topEightHandles.length > 0}
|
||||||
|
<ol class="top-eight-editor-list">
|
||||||
|
{#each topEightHandles as handle, index (handle)}
|
||||||
|
<li>
|
||||||
|
<code>{handle}</code>
|
||||||
|
<span class="top-eight-editor-actions">
|
||||||
|
<button type="button" class="button button--small" aria-label={`Move ${handle} up`} disabled={index === 0} onclick={() => moveTopEight(index, -1)}>Up</button>
|
||||||
|
<button type="button" class="button button--small" aria-label={`Move ${handle} down`} disabled={index === topEightHandles.length - 1} onclick={() => moveTopEight(index, 1)}>Down</button>
|
||||||
|
<button type="button" class="button button--small" aria-label={`Remove ${handle} from Top 8`} onclick={() => removeTopEight(index)}>Remove</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-note">You have not picked a Top 8 yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if topEightHandles.length < TOP_EIGHT_MAX}
|
||||||
|
<div class="top-eight-search">
|
||||||
|
<label class="visually-hidden" for="top-eight-search">Find someone for your Top 8</label>
|
||||||
|
<input id="top-eight-search" class="field-input" type="search" bind:value={topEightQuery} placeholder="@friend@server.example" />
|
||||||
|
<button type="button" class="button" disabled={topEightSearching || !topEightQuery.trim()} onclick={() => void searchTopEight()}>{topEightSearching ? 'Finding…' : 'Find person'}</button>
|
||||||
|
</div>
|
||||||
|
{#if topEightSearchError}<p class="error-note" role="alert">{topEightSearchError}</p>{/if}
|
||||||
|
{#if topEightResults.length > 0}
|
||||||
|
<ul class="top-eight-search-results">
|
||||||
|
{#each topEightResults as candidate (candidate.id)}
|
||||||
|
<li>
|
||||||
|
<button type="button" class="top-eight-result" onclick={() => addTopEight(candidate)}>
|
||||||
|
<img src={candidate.avatar_static || candidate.avatar} alt="" />
|
||||||
|
<span><strong>{candidate.display_name || candidate.username}</strong><br /><code>{fullHandle(candidate, instanceDomain(session.instance, session.host))}</code></span>
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class:field-error={bioCharactersLeft < 0} class="field-hint">
|
||||||
|
{topEightHandles.length} of {TOP_EIGHT_MAX} selected.
|
||||||
|
{#if bioCharactersLeft >= 0}
|
||||||
|
About {bioCharactersLeft.toLocaleString()} bio characters remain.
|
||||||
|
{:else}
|
||||||
|
{#if bioLimit.estimated}
|
||||||
|
This is about {Math.abs(bioCharactersLeft).toLocaleString()} characters over plspace's estimate;
|
||||||
|
your server will make the final decision when you save.
|
||||||
|
{:else}
|
||||||
|
Shorten your bio or Top 8 by {Math.abs(bioCharactersLeft).toLocaleString()} characters before saving.
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
{:else}
|
||||||
|
<p class="notice">
|
||||||
|
Top 8 editing is unavailable because your existing bio leaves too little room under this server's estimated profile limit.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<fieldset class="profile-editor-images">
|
<fieldset class="profile-editor-images">
|
||||||
<legend>Profile images</legend>
|
<legend>Profile images</legend>
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,41 @@ describe('PublicProfileEditor', () => {
|
|||||||
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
|
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('searches for Top 8 people and stores the portable list in the bio', async () => {
|
||||||
|
const me = credential()
|
||||||
|
const candidate = account({
|
||||||
|
id: 'friend',
|
||||||
|
username: 'friend',
|
||||||
|
acct: 'friend@remote.test',
|
||||||
|
display_name: 'Best Friend',
|
||||||
|
avatar_static: 'https://media.example/friend.png',
|
||||||
|
})
|
||||||
|
const search = vi.fn().mockResolvedValue({ accounts: [candidate], statuses: [], hashtags: [] })
|
||||||
|
const updatePublicProfile = vi.fn(async (_api, update: PublicProfileUpdate) => ({
|
||||||
|
...me,
|
||||||
|
source: { ...me.source!, note: update.note ?? '', fields: update.fields ?? me.source!.fields },
|
||||||
|
}))
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ token: 'token', me, signedIn: true }),
|
||||||
|
endpoints: { search, updatePublicProfile },
|
||||||
|
})
|
||||||
|
const view = render(PublicProfileEditor, {
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.input(view.getByLabelText('Find someone for your Top 8'), {
|
||||||
|
target: { value: '@friend@remote.test' },
|
||||||
|
})
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Find person' }))
|
||||||
|
await fireEvent.click(await view.findByRole('button', { name: /Best Friend/ }))
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Save public profile' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce())
|
||||||
|
expect(updatePublicProfile.mock.calls[0][1].note).toBe(
|
||||||
|
'Old bio\n\nMy top 8:\n1. @friend@remote.test',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('shows and submits only the profile fields Egregoros exposes through its API', async () => {
|
it('shows and submits only the profile fields Egregoros exposes through its API', async () => {
|
||||||
const me = credential()
|
const me = credential()
|
||||||
const updatePublicProfile = vi.fn().mockResolvedValue(me)
|
const updatePublicProfile = vi.fn().mockResolvedValue(me)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account, CustomEmoji } from '$lib/api/types'
|
||||||
|
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||||
|
import Module from '../common/Module.svelte'
|
||||||
|
import Avatar from '../common/Avatar.svelte'
|
||||||
|
import EmojiText from '../common/EmojiText.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ownerName: string
|
||||||
|
ownerEmojis?: CustomEmoji[]
|
||||||
|
accounts: Account[]
|
||||||
|
missing?: string[]
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
ownerName,
|
||||||
|
ownerEmojis,
|
||||||
|
accounts,
|
||||||
|
missing = [],
|
||||||
|
loading = false,
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Module title={`${ownerName}'s Top 8`} titleEmojis={ownerEmojis} variant="band" class="top-eight-space">
|
||||||
|
{#if loading && accounts.length === 0}
|
||||||
|
<p class="loading-note">Putting the Top 8 together…</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="friend-grid friend-grid--compact top-eight-grid">
|
||||||
|
{#each accounts as friend (friend.id)}
|
||||||
|
<li class="friend-card" data-account={friend.acct}>
|
||||||
|
<a class="friend-card-link" href={profilePath(friend)}>
|
||||||
|
<EmojiText class="friend-card-name" text={displayNameOf(friend)} emojis={friend.emojis} />
|
||||||
|
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if missing.length > 0}
|
||||||
|
<p class="top-eight-missing muted">
|
||||||
|
Could not find {missing.join(', ')} from this server.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</Module>
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fetchQuotes,
|
fetchQuotes,
|
||||||
postStatus,
|
postStatus,
|
||||||
setEmojiReaction,
|
setEmojiReaction,
|
||||||
|
uploadMedia,
|
||||||
updateProfileFields,
|
updateProfileFields,
|
||||||
updatePublicProfile,
|
updatePublicProfile,
|
||||||
votePoll,
|
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', () => {
|
describe('poll endpoints', () => {
|
||||||
it('submits selected poll option indexes as JSON', async () => {
|
it('submits selected poll option indexes as JSON', async () => {
|
||||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export interface CredentialAccount extends Account {
|
|||||||
|
|
||||||
export interface MediaAttachment {
|
export interface MediaAttachment {
|
||||||
id: string
|
id: string
|
||||||
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
|
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' | 'flash'
|
||||||
url: string
|
url: string
|
||||||
preview_url: string | null
|
preview_url: string | null
|
||||||
remote_url?: string | null
|
remote_url?: string | null
|
||||||
@@ -121,6 +121,12 @@ export interface MediaAttachment {
|
|||||||
small?: { width?: number; height?: number; aspect?: number }
|
small?: { width?: number; height?: number; aspect?: number }
|
||||||
[key: string]: unknown
|
[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 {
|
export interface StatusMention {
|
||||||
@@ -344,6 +350,8 @@ export interface InstanceInfo {
|
|||||||
}
|
}
|
||||||
accounts?: {
|
accounts?: {
|
||||||
max_profile_fields?: number
|
max_profile_fields?: number
|
||||||
|
/** Mastodon 4.6+ bio limit. */
|
||||||
|
max_note_length?: number
|
||||||
/** Pleroma v2 names. */
|
/** Pleroma v2 names. */
|
||||||
profile_field_name_limit?: number
|
profile_field_name_limit?: number
|
||||||
profile_field_value_limit?: number
|
profile_field_value_limit?: number
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import type { Account } from '../api/types'
|
import type { Account } from '../api/types'
|
||||||
import { renderHtml, toPlainText } from './html'
|
import { renderHtml, toPlainText } from './html'
|
||||||
|
import { topEightFromHtml } from './top-eight'
|
||||||
|
|
||||||
/** The interest rows a MySpace profile shipped with, in their original order. */
|
/** The interest rows a MySpace profile shipped with, in their original order. */
|
||||||
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
|
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
|
||||||
@@ -70,6 +71,8 @@ export interface ProfileView {
|
|||||||
about: string
|
about: string
|
||||||
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
|
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
|
||||||
wantsToMeet: string
|
wantsToMeet: string
|
||||||
|
/** Fully-qualified handles declared in the portable bio section. */
|
||||||
|
topEightHandles: string[]
|
||||||
interests: InterestEntry[]
|
interests: InterestEntry[]
|
||||||
details: ProfileField[]
|
details: ProfileField[]
|
||||||
}
|
}
|
||||||
@@ -124,7 +127,9 @@ export function fallbackMood(seed: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildProfileView(account: Account): ProfileView {
|
export function buildProfileView(account: Account): ProfileView {
|
||||||
const noteHtml = renderHtml(account.note, { emojis: account.emojis })
|
const renderedNote = renderHtml(account.note, { emojis: account.emojis })
|
||||||
|
const topEight = topEightFromHtml(renderedNote)
|
||||||
|
const noteHtml = topEight.html
|
||||||
const { about, meet } = splitBio(noteHtml)
|
const { about, meet } = splitBio(noteHtml)
|
||||||
|
|
||||||
const interests: InterestEntry[] = []
|
const interests: InterestEntry[] = []
|
||||||
@@ -155,7 +160,7 @@ export function buildProfileView(account: Account): ProfileView {
|
|||||||
interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row))
|
interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row))
|
||||||
|
|
||||||
const headlineField = findField(account, ['headline', 'status'])
|
const headlineField = findField(account, ['headline', 'status'])
|
||||||
const headline = headlineField ?? firstSentence(toPlainText(account.note)) ?? '"..."'
|
const headline = headlineField ?? firstSentence(toPlainText(noteHtml)) ?? '"..."'
|
||||||
|
|
||||||
const ageField = findField(account, ['age'])
|
const ageField = findField(account, ['age'])
|
||||||
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
|
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
|
||||||
@@ -169,6 +174,7 @@ export function buildProfileView(account: Account): ProfileView {
|
|||||||
age: Number.isFinite(parsedAge) ? parsedAge : null,
|
age: Number.isFinite(parsedAge) ? parsedAge : null,
|
||||||
about,
|
about,
|
||||||
wantsToMeet: meet,
|
wantsToMeet: meet,
|
||||||
|
topEightHandles: topEight.handles,
|
||||||
interests,
|
interests,
|
||||||
details,
|
details,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseTopEightText, topEightFromHtml, withTopEight } from './top-eight'
|
||||||
|
|
||||||
|
describe('Top 8 profile bio format', () => {
|
||||||
|
it('detects numbered and unnumbered fully-qualified handles', () => {
|
||||||
|
expect(parseTopEightText([
|
||||||
|
'I like old websites.',
|
||||||
|
'',
|
||||||
|
'My top 8:',
|
||||||
|
'1. @alice@example.test',
|
||||||
|
'bob@remote.test',
|
||||||
|
'3. @carol@social.example',
|
||||||
|
'',
|
||||||
|
'This remains in the bio.',
|
||||||
|
].join('\n'))).toEqual({
|
||||||
|
handles: ['@alice@example.test', '@bob@remote.test', '@carol@social.example'],
|
||||||
|
bio: 'I like old websites.\n\nThis remains in the bio.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts local-only @handles in manually written profile lists', () => {
|
||||||
|
expect(parseTopEightText([
|
||||||
|
'About me',
|
||||||
|
'My top 8:',
|
||||||
|
'1. @localfriend',
|
||||||
|
'2. @remote@social.example',
|
||||||
|
'3. @another_local',
|
||||||
|
].join('\n'))).toEqual({
|
||||||
|
handles: ['@localfriend', '@remote@social.example', '@another_local'],
|
||||||
|
bio: 'About me',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores headings without at least one qualified handle', () => {
|
||||||
|
expect(parseTopEightText('My top 8:\nAlice\nBob')).toEqual({
|
||||||
|
handles: [],
|
||||||
|
bio: 'My top 8:\nAlice\nBob',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces an existing section and caps it at eight unique handles', () => {
|
||||||
|
const next = withTopEight(
|
||||||
|
'Bio\n\nMy top 8:\n@old@example.test',
|
||||||
|
Array.from({ length: 10 }, (_, index) => `friend${index}@example.test`),
|
||||||
|
)
|
||||||
|
expect(next).toContain('Bio\n\nMy top 8:\n1. @friend0@example.test')
|
||||||
|
expect(next).toContain('8. @friend7@example.test')
|
||||||
|
expect(next).not.toContain('friend8')
|
||||||
|
expect(next).not.toContain('@old@example.test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the section from HTML without flattening the rest of the bio', () => {
|
||||||
|
const result = topEightFromHtml(
|
||||||
|
'<p><strong>Hello!</strong><br>My top 8:<br>1. <a href="https://example.test/@alice">@alice@example.test</a><br>@bob@remote.test</p><p><em>Still here.</em></p>',
|
||||||
|
)
|
||||||
|
expect(result.handles).toEqual(['@alice@example.test', '@bob@remote.test'])
|
||||||
|
expect(result.html).toContain('<strong>Hello!</strong>')
|
||||||
|
expect(result.html).toContain('<em>Still here.</em>')
|
||||||
|
expect(result.html).not.toContain('My top 8')
|
||||||
|
expect(result.html).not.toContain('@alice@example.test')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import type { Account, InstanceInfo } from '../api/types'
|
||||||
|
|
||||||
|
export const TOP_EIGHT_HEADING = 'My top 8:'
|
||||||
|
export const TOP_EIGHT_MAX = 8
|
||||||
|
|
||||||
|
const HEADING_PATTERN = /^\s*my\s+top\s+8\s*:\s*$/i
|
||||||
|
const HANDLE_PATTERN = /^\s*(?:[1-8]\.\s*)?((?:@[a-z0-9_][a-z0-9_.-]*)(?:@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)?|(?:[a-z0-9_][a-z0-9_.-]*@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?))\s*$/i
|
||||||
|
|
||||||
|
export interface ParsedTopEight {
|
||||||
|
handles: string[]
|
||||||
|
/** The bio with only the recognized Top 8 section removed. */
|
||||||
|
bio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHandle(value: string): string | null {
|
||||||
|
const match = HANDLE_PATTERN.exec(value)
|
||||||
|
if (!match) return null
|
||||||
|
return `@${match[1].replace(/^@/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local-only handles are accepted when reading, but picker-written handles are qualified. */
|
||||||
|
export function topEightHandleMatchesAccount(handle: string, account: Account, localHost: string): boolean {
|
||||||
|
const normalized = handle.toLowerCase()
|
||||||
|
return normalized === `@${account.acct}`.toLowerCase() ||
|
||||||
|
normalized === `@${account.username}`.toLowerCase() ||
|
||||||
|
normalized === `@${account.username}@${localHost}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the portable, human-readable representation used in profile bios. */
|
||||||
|
export function parseTopEightText(source: string): ParsedTopEight {
|
||||||
|
const normalized = source.replace(/\r\n?/g, '\n')
|
||||||
|
const lines = normalized.split('\n')
|
||||||
|
|
||||||
|
for (let heading = 0; heading < lines.length; heading += 1) {
|
||||||
|
if (!HEADING_PATTERN.test(lines[heading])) continue
|
||||||
|
const handles: string[] = []
|
||||||
|
let end = heading + 1
|
||||||
|
while (end < lines.length && handles.length < TOP_EIGHT_MAX) {
|
||||||
|
const handle = normalizedHandle(lines[end])
|
||||||
|
if (!handle) break
|
||||||
|
handles.push(handle)
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
if (handles.length === 0) continue
|
||||||
|
|
||||||
|
const before = lines.slice(0, heading)
|
||||||
|
const after = lines.slice(end)
|
||||||
|
return {
|
||||||
|
handles,
|
||||||
|
bio: [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handles: [], bio: normalized.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace an existing section without disturbing the user's ordinary bio. */
|
||||||
|
export function withTopEight(source: string, handles: string[]): string {
|
||||||
|
const base = parseTopEightText(source).bio
|
||||||
|
const unique = Array.from(
|
||||||
|
new Set(handles.map((handle) => normalizedHandle(handle)).filter((handle): handle is string => Boolean(handle))),
|
||||||
|
).slice(0, TOP_EIGHT_MAX)
|
||||||
|
if (unique.length === 0) return base
|
||||||
|
const section = [TOP_EIGHT_HEADING, ...unique.map((handle, index) => `${index + 1}. ${handle}`)].join('\n')
|
||||||
|
return base ? `${base}\n\n${section}` : section
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectedLine {
|
||||||
|
text: string
|
||||||
|
textNodes: Text[]
|
||||||
|
breaks: HTMLBRElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project an HTML bio into lines while retaining the nodes that formed them.
|
||||||
|
* This lets us remove the portable section without flattening links, emphasis,
|
||||||
|
* custom emoji, or any other formatting in the rest of the bio.
|
||||||
|
*/
|
||||||
|
function projectedLines(container: HTMLElement): ProjectedLine[] {
|
||||||
|
const lines: ProjectedLine[] = [{ text: '', textNodes: [], breaks: [] }]
|
||||||
|
const current = () => lines[lines.length - 1]
|
||||||
|
const newline = (br?: HTMLBRElement) => {
|
||||||
|
if (br) current().breaks.push(br)
|
||||||
|
if (current().text || current().textNodes.length || current().breaks.length) {
|
||||||
|
lines.push({ text: '', textNodes: [], breaks: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blocks = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4'])
|
||||||
|
|
||||||
|
const visit = (node: Node): void => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? ''
|
||||||
|
const parts = text.split('\n')
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
if (part) {
|
||||||
|
current().text += part
|
||||||
|
current().textNodes.push(node as Text)
|
||||||
|
}
|
||||||
|
if (index < parts.length - 1) newline()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (node.tagName === 'BR') {
|
||||||
|
newline(node as HTMLBRElement)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const block = blocks.has(node.tagName)
|
||||||
|
if (block && current().text.trim()) newline()
|
||||||
|
for (const child of Array.from(node.childNodes)) visit(child)
|
||||||
|
if (block && current().text.trim()) newline()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(container.childNodes)) visit(child)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topEightFromHtml(source: string): { handles: string[]; html: string } {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = DOMPurify.sanitize(source)
|
||||||
|
const lines = projectedLines(container)
|
||||||
|
|
||||||
|
for (let heading = 0; heading < lines.length; heading += 1) {
|
||||||
|
if (!HEADING_PATTERN.test(lines[heading].text)) continue
|
||||||
|
const handles: string[] = []
|
||||||
|
let end = heading + 1
|
||||||
|
while (end < lines.length && handles.length < TOP_EIGHT_MAX) {
|
||||||
|
const handle = normalizedHandle(lines[end].text)
|
||||||
|
if (!handle) break
|
||||||
|
handles.push(handle)
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
if (handles.length === 0) continue
|
||||||
|
|
||||||
|
for (const line of lines.slice(heading, end)) {
|
||||||
|
for (const node of new Set(line.textNodes)) node.textContent = ''
|
||||||
|
for (const br of line.breaks) br.remove()
|
||||||
|
}
|
||||||
|
for (const empty of Array.from(container.querySelectorAll('p, div, li, blockquote, pre'))) {
|
||||||
|
if (!(empty.textContent ?? '').trim() && !empty.querySelector('img')) empty.remove()
|
||||||
|
}
|
||||||
|
return { handles, html: container.innerHTML.trim() }
|
||||||
|
}
|
||||||
|
return { handles: [], html: container.innerHTML.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best available limit. Pleroma does not currently advertise user_bio_length. */
|
||||||
|
export function profileBioLimit(instance: InstanceInfo | null): { value: number; estimated: boolean } {
|
||||||
|
const advertised = instance?.configuration?.accounts?.max_note_length
|
||||||
|
if (typeof advertised === 'number' && advertised > 0) return { value: advertised, estimated: false }
|
||||||
|
if (instance?.pleroma) return { value: 5000, estimated: true }
|
||||||
|
return { value: 500, estimated: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_PREFIX = 'plspace:top-eight:v1:'
|
||||||
|
const CACHE_TTL = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export function readTopEightCache(host: string, ownerId: string, handles: string[]): Account[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${CACHE_PREFIX}${host}:${ownerId}`)
|
||||||
|
if (!raw) return null
|
||||||
|
const cached = JSON.parse(raw) as { savedAt: number; handles: string[]; accounts: Account[] }
|
||||||
|
if (Date.now() - cached.savedAt > CACHE_TTL || cached.handles.join('\n') !== handles.join('\n')) return null
|
||||||
|
return cached.accounts
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTopEightCache(host: string, ownerId: string, handles: string[], accounts: Account[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
`${CACHE_PREFIX}${host}:${ownerId}`,
|
||||||
|
JSON.stringify({ savedAt: Date.now(), handles, accounts }),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Private browsing and storage quotas should never break a public profile.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,6 +200,12 @@
|
|||||||
return fallbackMood(status.account.id)
|
return fallbackMood(status.account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The compact home row otherwise has no output for a media-only status. */
|
||||||
|
function imagePreviewFor(status: Status) {
|
||||||
|
if (toPlainText(status.spoiler_text || status.content)) return null
|
||||||
|
return status.media_attachments.find((attachment) => attachment.type === 'image') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function messageOf(cause: unknown): string {
|
function messageOf(cause: unknown): string {
|
||||||
return cause instanceof Error ? cause.message : 'Could not load that.'
|
return cause instanceof Error ? cause.message : 'Could not load that.'
|
||||||
}
|
}
|
||||||
@@ -332,6 +338,7 @@
|
|||||||
{#each friendStatus as status (status.id)}
|
{#each friendStatus as status (status.id)}
|
||||||
{@const entry = status.reblog ?? status}
|
{@const entry = status.reblog ?? status}
|
||||||
{@const author = accountForStatus(entry, preferences.heleneposting)}
|
{@const author = accountForStatus(entry, preferences.heleneposting)}
|
||||||
|
{@const imagePreview = imagePreviewFor(entry)}
|
||||||
<li class="status-line" data-account={entry.account.acct}>
|
<li class="status-line" data-account={entry.account.acct}>
|
||||||
<Avatar account={author} />
|
<Avatar account={author} />
|
||||||
<div class="status-line-body">
|
<div class="status-line-body">
|
||||||
@@ -345,6 +352,22 @@
|
|||||||
tags={entry.tags}
|
tags={entry.tags}
|
||||||
inline
|
inline
|
||||||
/>
|
/>
|
||||||
|
{#if imagePreview}
|
||||||
|
<a
|
||||||
|
class="status-line-media"
|
||||||
|
data-sensitive={entry.sensitive ? 'true' : 'false'}
|
||||||
|
href={`#/blog/${entry.id}`}
|
||||||
|
aria-label={imagePreview.description || 'View image post'}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="status-line-media-image"
|
||||||
|
src={imagePreview.preview_url ?? imagePreview.url}
|
||||||
|
alt={imagePreview.description ?? ''}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<a class="status-line-time" href={`#/blog/${entry.id}`}>
|
<a class="status-line-time" href={`#/blog/${entry.id}`}>
|
||||||
{relativeTime(entry.created_at)}
|
{relativeTime(entry.created_at)}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+39
-1
@@ -2,7 +2,7 @@ import { render, waitFor } from '@testing-library/svelte'
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_SERVICES } from '$lib/app-services'
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh'
|
import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh'
|
||||||
import { account, session, testServices } from '$test/fixtures'
|
import { account, session, status, testServices } from '$test/fixtures'
|
||||||
import Home from './Home.svelte'
|
import Home from './Home.svelte'
|
||||||
|
|
||||||
describe('Home timeline refresh', () => {
|
describe('Home timeline refresh', () => {
|
||||||
@@ -38,3 +38,41 @@ describe('Home timeline refresh', () => {
|
|||||||
expect(fetchNotifications).toHaveBeenCalledOnce()
|
expect(fetchNotifications).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Home Friend Status previews', () => {
|
||||||
|
it('shows a small preview for an image-only post', async () => {
|
||||||
|
const imagePost = status({
|
||||||
|
id: 'image-only',
|
||||||
|
content: '',
|
||||||
|
media_attachments: [
|
||||||
|
{
|
||||||
|
id: 'photo',
|
||||||
|
type: 'image',
|
||||||
|
url: 'https://media.example/full.jpg',
|
||||||
|
preview_url: 'https://media.example/small.jpg',
|
||||||
|
description: 'A tiny cat',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const fetchTimeline = vi.fn(async (_api, kind: string) => ({
|
||||||
|
items: kind === 'home' ? [imagePost] : [],
|
||||||
|
links: {},
|
||||||
|
}))
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||||
|
endpoints: {
|
||||||
|
fetchTimeline,
|
||||||
|
fetchFollowing: vi.fn().mockResolvedValue({ items: [], links: {} }),
|
||||||
|
fetchNotifications: vi.fn().mockResolvedValue({ items: [], links: {} }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const view = render(Home, {
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = await view.findByRole('img', { name: 'A tiny cat' })
|
||||||
|
expect(preview).toHaveAttribute('src', 'https://media.example/small.jpg')
|
||||||
|
expect(preview).toHaveClass('status-line-media-image')
|
||||||
|
expect(preview.closest('a')).toHaveAttribute('href', '#/blog/image-only')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
formatCount,
|
formatCount,
|
||||||
} from '$lib/util/profile'
|
} from '$lib/util/profile'
|
||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
|
import {
|
||||||
|
readTopEightCache,
|
||||||
|
topEightHandleMatchesAccount,
|
||||||
|
writeTopEightCache,
|
||||||
|
} from '$lib/util/top-eight'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import EmojiText from '$components/common/EmojiText.svelte'
|
import EmojiText from '$components/common/EmojiText.svelte'
|
||||||
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
|
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
|
||||||
@@ -31,6 +36,7 @@
|
|||||||
import InterestsTable from '$components/profile/InterestsTable.svelte'
|
import InterestsTable from '$components/profile/InterestsTable.svelte'
|
||||||
import DetailsTable from '$components/profile/DetailsTable.svelte'
|
import DetailsTable from '$components/profile/DetailsTable.svelte'
|
||||||
import FriendSpace from '$components/profile/FriendSpace.svelte'
|
import FriendSpace from '$components/profile/FriendSpace.svelte'
|
||||||
|
import TopEightSpace from '$components/profile/TopEightSpace.svelte'
|
||||||
import PicStream from '$components/profile/PicStream.svelte'
|
import PicStream from '$components/profile/PicStream.svelte'
|
||||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||||
import Pager from '$components/common/Pager.svelte'
|
import Pager from '$components/common/Pager.svelte'
|
||||||
@@ -52,6 +58,9 @@
|
|||||||
|
|
||||||
let friends = $state<Account[]>([])
|
let friends = $state<Account[]>([])
|
||||||
let friendsLoading = $state(false)
|
let friendsLoading = $state(false)
|
||||||
|
let topEightAccounts = $state<Account[]>([])
|
||||||
|
let topEightMissing = $state<string[]>([])
|
||||||
|
let topEightLoading = $state(false)
|
||||||
let loadGeneration = 0
|
let loadGeneration = 0
|
||||||
|
|
||||||
// Recreated whenever the account changes, so the feed never shows one
|
// Recreated whenever the account changes, so the feed never shows one
|
||||||
@@ -105,6 +114,9 @@
|
|||||||
relationship = null
|
relationship = null
|
||||||
friends = []
|
friends = []
|
||||||
friendsLoading = false
|
friendsLoading = false
|
||||||
|
topEightAccounts = []
|
||||||
|
topEightMissing = []
|
||||||
|
topEightLoading = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const found = await endpoints.lookupAccount(session.api, handle)
|
const found = await endpoints.lookupAccount(session.api, handle)
|
||||||
@@ -145,6 +157,7 @@
|
|||||||
)
|
)
|
||||||
void entries.reload()
|
void entries.reload()
|
||||||
|
|
||||||
|
void loadTopEight(found, generation)
|
||||||
void loadFriends(found, currentView, generation)
|
void loadFriends(found, currentView, generation)
|
||||||
void loadRelationship(found, generation)
|
void loadRelationship(found, generation)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -155,6 +168,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTopEight(target: Account, generation: number): Promise<void> {
|
||||||
|
const handles = buildProfileView(target).topEightHandles
|
||||||
|
if (handles.length === 0) return
|
||||||
|
|
||||||
|
const cached = readTopEightCache(session.host, target.id, handles)
|
||||||
|
if (cached) {
|
||||||
|
topEightAccounts = cached
|
||||||
|
topEightMissing = handles.filter(
|
||||||
|
(handle) => !cached.some((item) => topEightHandleMatchesAccount(handle, item, session.host)),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topEightLoading = true
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
handles.map((handle) => endpoints.lookupAccount(session.api, handle)),
|
||||||
|
)
|
||||||
|
if (generation !== loadGeneration || account?.id !== target.id) return
|
||||||
|
topEightAccounts = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [])
|
||||||
|
topEightMissing = handles.filter((_, index) => settled[index].status === 'rejected')
|
||||||
|
writeTopEightCache(session.host, target.id, handles, topEightAccounts)
|
||||||
|
topEightLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFriends(
|
async function loadFriends(
|
||||||
target: Account,
|
target: Account,
|
||||||
currentView: Props['view'],
|
currentView: Props['view'],
|
||||||
@@ -394,6 +431,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Module>
|
</Module>
|
||||||
|
|
||||||
|
{#if profile.topEightHandles.length > 0}
|
||||||
|
<TopEightSpace
|
||||||
|
ownerName={firstName}
|
||||||
|
ownerEmojis={account.emojis}
|
||||||
|
accounts={topEightAccounts}
|
||||||
|
missing={topEightMissing}
|
||||||
|
loading={topEightLoading}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<FriendSpace
|
<FriendSpace
|
||||||
title={`${firstName}'s Friend Space`}
|
title={`${firstName}'s Friend Space`}
|
||||||
ownerName={firstName}
|
ownerName={firstName}
|
||||||
|
|||||||
@@ -113,6 +113,44 @@ describe('Profile', () => {
|
|||||||
expect(view.queryByText('A reply')).not.toBeInTheDocument()
|
expect(view.queryByText('A reply')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('turns a portable bio list into a Top 8 grid and hides its source text', async () => {
|
||||||
|
const owner = account({
|
||||||
|
id: 'owner',
|
||||||
|
note: '<p>Hello from my profile.<br>My top 8:<br>1. @bob<br>@carol@social.test</p>',
|
||||||
|
})
|
||||||
|
const bob = account({ id: 'bob', username: 'bob', acct: 'bob@remote.test', display_name: 'Bob' })
|
||||||
|
const carol = account({ id: 'carol', username: 'carol', acct: 'carol@social.test', display_name: 'Carol' })
|
||||||
|
const lookupAccount = vi.fn(async (_api, handle: string) => {
|
||||||
|
if (handle === 'alice') return owner
|
||||||
|
if (handle === '@bob') return bob
|
||||||
|
if (handle === '@carol@social.test') return carol
|
||||||
|
throw new Error('not found')
|
||||||
|
})
|
||||||
|
const services = testServices({
|
||||||
|
session: session(),
|
||||||
|
theme: theme(),
|
||||||
|
endpoints: {
|
||||||
|
lookupAccount,
|
||||||
|
fetchAccountStatuses: vi.fn().mockResolvedValue({ items: [], links: {} }),
|
||||||
|
fetchFollowers: vi.fn().mockResolvedValue({ items: [], links: {} }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const view = render(Profile, {
|
||||||
|
props: { acct: 'alice' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await view.findByRole('heading', { name: "Alice's Top 8" })).toBeInTheDocument()
|
||||||
|
expect(await view.findByText('Bob')).toBeInTheDocument()
|
||||||
|
expect(await view.findByText('Carol')).toBeInTheDocument()
|
||||||
|
expect(view.container.querySelector('.top-eight-space .friend-count')).not.toBeInTheDocument()
|
||||||
|
expect(view.queryByRole('link', { name: "View All of Alice's Friends" })).not.toBeInTheDocument()
|
||||||
|
expect(view.getByRole('link', { name: '[view all]' })).toHaveAttribute('href', '#/@alice/friends')
|
||||||
|
expect(view.getAllByText('Hello from my profile.').length).toBeGreaterThan(0)
|
||||||
|
expect(view.queryByText('My top 8:')).not.toBeInTheDocument()
|
||||||
|
expect(view.queryByText('@bob@remote.test')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('builds Pics from the account’s own image attachments', async () => {
|
it('builds Pics from the account’s own image attachments', async () => {
|
||||||
const ownPicture = status({
|
const ownPicture = status({
|
||||||
id: 'picture-entry',
|
id: 'picture-entry',
|
||||||
|
|||||||
+94
-6
@@ -32,19 +32,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-entry-header {
|
.blog-entry-header {
|
||||||
display: flex;
|
position: relative;
|
||||||
gap: 8px;
|
margin-bottom: 0;
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-entry-avatar {
|
.blog-entry-avatar {
|
||||||
flex: 0 0 auto;
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-entry-byline {
|
.blog-entry-byline {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
margin-left: calc(var(--ms-avatar-size) + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-entry[data-compact='true'] .blog-entry-byline {
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-entry-author {
|
.blog-entry-author {
|
||||||
@@ -148,6 +151,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-media {
|
.attachment-media {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -177,6 +184,68 @@
|
|||||||
font-size: var(--ms-font-size-small);
|
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 links become privacy-enhanced players in the attachment space. */
|
||||||
.youtube-attachment-list {
|
.youtube-attachment-list {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||||
@@ -638,3 +707,22 @@
|
|||||||
color: var(--ms-muted-fg);
|
color: var(--ms-muted-fg);
|
||||||
font-size: var(--ms-font-size-small);
|
font-size: var(--ms-font-size-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-line-media {
|
||||||
|
display: block;
|
||||||
|
width: min(96px, 100%);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line-media-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--ms-avatar-border);
|
||||||
|
background: var(--ms-table-stripe-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line-media[data-sensitive='true'] .status-line-media-image {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,15 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `.link-button` normally uses the page's navy link colour. In the navy
|
||||||
|
utility bar that makes LogOut disappear, so mutations in this region use
|
||||||
|
the same high-contrast token as its anchors. */
|
||||||
|
.site-header-logout,
|
||||||
|
.site-header-logout:hover,
|
||||||
|
.site-header-logout:focus-visible {
|
||||||
|
color: var(--ms-chrome-link);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- nav row */
|
/* ---------------------------------------------------------------- nav row */
|
||||||
|
|
||||||
.site-nav {
|
.site-nav {
|
||||||
|
|||||||
+81
-1
@@ -176,13 +176,28 @@ textarea {
|
|||||||
border: 1px solid var(--ms-avatar-border);
|
border: 1px solid var(--ms-avatar-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-attachment img {
|
.composer-attachment img,
|
||||||
|
.composer-attachment-preview {
|
||||||
display: block;
|
display: block;
|
||||||
width: 78px;
|
width: 78px;
|
||||||
height: 78px;
|
height: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachment img {
|
||||||
object-fit: cover;
|
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 {
|
.composer-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -306,6 +321,71 @@ textarea {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-editor-top-eight {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-editor-list,
|
||||||
|
.top-eight-search-results {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-editor-list li {
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-search {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-search-results {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-result {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--ms-module-border);
|
||||||
|
background: var(--ms-canvas-bg);
|
||||||
|
color: var(--ms-page-fg);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-result:hover,
|
||||||
|
.top-eight-result:focus-visible {
|
||||||
|
background: var(--ms-table-stripe-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-result img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--ms-error-fg, #a00000);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-editor-field {
|
.profile-editor-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto;
|
grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto;
|
||||||
|
|||||||
@@ -232,6 +232,40 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-eight-space {
|
||||||
|
margin-bottom: var(--ms-module-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(64px, 1fr));
|
||||||
|
gap: 28px 18px;
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-grid .friend-card-name {
|
||||||
|
min-height: 2.4em;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-grid .friend-card-photo {
|
||||||
|
border: 2px solid var(--ms-link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-eight-missing {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: var(--ms-font-size-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.top-eight-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(64px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------- pics stream */
|
/* ------------------------------------------------------------- pics stream */
|
||||||
|
|
||||||
.pic-stream-intro {
|
.pic-stream-intro {
|
||||||
|
|||||||
+45
-1
@@ -2,12 +2,56 @@ import { defineConfig } from 'vitest/config'
|
|||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
import { svelteTesting } from '@testing-library/svelte/vite'
|
import { svelteTesting } from '@testing-library/svelte/vite'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
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
|
// 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).
|
// the browser, so `dist/` can be dropped on any static host (or file://-ish CDN).
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [svelte(), svelteTesting()],
|
plugins: [svelte(), svelteTesting(), ruffleAssets()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user