mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
Compare commits
8
Commits
5b7b4360d2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c76bab55dd | ||
|
|
80c6134359 | ||
|
|
7d16969f02 | ||
|
|
1b14d94f6a | ||
|
|
f735de5185 | ||
|
|
657d517503 | ||
|
|
49c9c2ba57 | ||
|
|
b814d79f19 |
@@ -16,9 +16,6 @@ you can host anywhere.
|
||||
The canonical repository is hosted on Radicle:
|
||||
`rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT`.
|
||||
|
||||
The backup mirror is
|
||||
[`https://git.shipoclu.com/moon/plspace.git`](https://git.shipoclu.com/moon/plspace.git).
|
||||
|
||||
## What it looks like
|
||||
|
||||
| MySpace | plspace |
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -12,11 +12,13 @@
|
||||
import Profile from '$routes/Profile.svelte'
|
||||
import Timeline from '$routes/Timeline.svelte'
|
||||
import StatusPage from '$routes/StatusPage.svelte'
|
||||
import Quotes from '$routes/Quotes.svelte'
|
||||
import Mail from '$routes/Mail.svelte'
|
||||
import Browse from '$routes/Browse.svelte'
|
||||
import Search from '$routes/Search.svelte'
|
||||
import Login from '$routes/Login.svelte'
|
||||
import Settings from '$routes/Settings.svelte'
|
||||
import About from '$routes/About.svelte'
|
||||
import Compose from '$routes/Compose.svelte'
|
||||
import NotFound from '$routes/NotFound.svelte'
|
||||
import type { TimelineKind } from '$lib/api/endpoints'
|
||||
@@ -49,7 +51,7 @@
|
||||
const route = $derived(router.current)
|
||||
|
||||
/** Route names that are usable before a server is chosen. */
|
||||
const ALWAYS_AVAILABLE = new Set(['login', 'settings', 'notfound'])
|
||||
const ALWAYS_AVAILABLE = new Set(['login', 'settings', 'about', 'notfound'])
|
||||
|
||||
const TIMELINE_KINDS: readonly TimelineKind[] = ['home', 'public', 'local']
|
||||
|
||||
@@ -89,6 +91,8 @@
|
||||
<Timeline kind="tag" tag={route.params.tag} />
|
||||
{:else if route.name === 'blog.entry'}
|
||||
<StatusPage id={route.params.id} />
|
||||
{:else if route.name === 'blog.quotes'}
|
||||
<Quotes id={route.params.id} />
|
||||
{:else if route.name === 'mail'}
|
||||
<Mail folder="inbox" />
|
||||
{:else if route.name === 'mail.folder'}
|
||||
@@ -98,11 +102,16 @@
|
||||
{:else if route.name === 'search'}
|
||||
<Search q={route.query.get('q') ?? ''} />
|
||||
{:else if route.name === 'compose'}
|
||||
<Compose to={route.query.get('to') ?? undefined} />
|
||||
<Compose
|
||||
to={route.query.get('to') ?? undefined}
|
||||
quoteId={route.query.get('quote') ?? undefined}
|
||||
/>
|
||||
{:else if route.name === 'login'}
|
||||
<Login />
|
||||
{:else if route.name === 'settings'}
|
||||
<Settings />
|
||||
{:else if route.name === 'about'}
|
||||
<About />
|
||||
{:else}
|
||||
<NotFound />
|
||||
{/if}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
@@ -6,14 +6,21 @@
|
||||
* when it's revealed, and the reveal is per-attachment because a single post
|
||||
* can mix flagged and unflagged media.
|
||||
*/
|
||||
import type { 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 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, sensitive = false }: Props = $props()
|
||||
let { attachments, emojis, sensitive = false, loadRuffle }: Props = $props()
|
||||
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
@@ -47,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)}
|
||||
@@ -81,7 +96,9 @@
|
||||
{/if}
|
||||
|
||||
{#if media.description}
|
||||
<figcaption class="attachment-caption">{media.description}</figcaption>
|
||||
<figcaption class="attachment-caption">
|
||||
<EmojiText text={media.description} {emojis} />
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { routeTo } from '$lib/router.svelte'
|
||||
import { quoteReferenceOf, quotesCountOf } from '$lib/util/status'
|
||||
import { accountForStatus } from '$lib/util/heleneposting'
|
||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
import MfmContent from '../common/MfmContent.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import EmojiReactions from './EmojiReactions.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
import QuoteCard from './QuoteCard.svelte'
|
||||
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -37,17 +43,26 @@
|
||||
|
||||
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
/** The status actually being displayed; a boost renders its target. */
|
||||
const entry = $derived(status.reblog ?? status)
|
||||
const booster = $derived(status.reblog ? status.account : null)
|
||||
|
||||
const author = $derived(entry.account)
|
||||
const author = $derived(accountForStatus(entry, preferences.heleneposting))
|
||||
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
|
||||
const handle = $derived(fullHandle(author, session.host))
|
||||
const permalink = $derived(`#/blog/${entry.id}`)
|
||||
const isMine = $derived(session.me?.id === entry.account.id)
|
||||
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
|
||||
const quote = $derived(quoteReferenceOf(entry))
|
||||
const quotesCount = $derived(quotesCountOf(entry))
|
||||
const canQuote = $derived(
|
||||
entry.visibility !== 'direct' &&
|
||||
!['denied', 'unknown'].includes(entry.quote_approval?.current_user ?? '') &&
|
||||
(entry.visibility !== 'private' ||
|
||||
session.me?.id === entry.account.id ||
|
||||
Boolean(entry.quote_approval)),
|
||||
)
|
||||
|
||||
let busy = $state(false)
|
||||
let actionError = $state<string | null>(null)
|
||||
@@ -144,7 +159,10 @@
|
||||
>
|
||||
{#if booster}
|
||||
<p class="blog-entry-attribution">
|
||||
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
|
||||
<a href={profilePath(booster)}>
|
||||
<EmojiText text={displayNameOf(booster)} emojis={booster.emojis} />
|
||||
</a>
|
||||
reposted this
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -180,8 +198,11 @@
|
||||
<div class="blog-entry-body">
|
||||
{#if entry.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
||||
<summary class="content-warning-summary">
|
||||
<EmojiText text={entry.spoiler_text} emojis={entry.emojis} />
|
||||
</summary>
|
||||
<MfmContent
|
||||
class={quote ? 'quote-post-content' : ''}
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
@@ -189,12 +210,13 @@
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
<Attachments attachments={entry.media_attachments} emojis={entry.emojis} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||
</details>
|
||||
{:else}
|
||||
<MfmContent
|
||||
class={quote ? 'quote-post-content' : ''}
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
@@ -202,20 +224,25 @@
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
<Attachments attachments={entry.media_attachments} emojis={entry.emojis} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
|
||||
{#if quote}
|
||||
<QuoteCard {quote} />
|
||||
{/if}
|
||||
|
||||
{#if entry.poll}
|
||||
<PollView
|
||||
poll={entry.poll}
|
||||
emojis={entry.poll.emojis?.length ? entry.poll.emojis : entry.emojis}
|
||||
authorId={entry.account.id}
|
||||
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||
{#if entry.card && !quote && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||
<PreviewCardView card={entry.card} />
|
||||
{/if}
|
||||
|
||||
@@ -254,6 +281,26 @@
|
||||
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
|
||||
</button>
|
||||
|
||||
{#if canQuote}
|
||||
<a
|
||||
class="blog-action blog-action--quote"
|
||||
href={session.signedIn ? routeTo('/compose', { quote: entry.id }) : '#/login'}
|
||||
title={session.signedIn ? 'Quote this entry' : 'Sign in to quote this entry'}
|
||||
>
|
||||
Quote
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if quotesCount > 0}
|
||||
<a
|
||||
class="blog-action blog-action--quotes"
|
||||
href={session.signedIn ? `#/blog/${entry.id}/quotes` : '#/login'}
|
||||
title={session.signedIn ? 'View entries quoting this' : 'Sign in to view quotes'}
|
||||
>
|
||||
Quotes <span class="blog-action-count">({formatCount(quotesCount)})</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if entry.url}
|
||||
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
Original
|
||||
@@ -266,5 +313,10 @@
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
|
||||
<EmojiReactions
|
||||
status={entry}
|
||||
onupdate={(updated) => onupdate?.(rewrap(status, updated))}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { status, testServices } from '$test/fixtures'
|
||||
import { account, preferences, session, status, testServices } from '$test/fixtures'
|
||||
import BlogEntry from './BlogEntry.svelte'
|
||||
|
||||
function renderEntry(overrides: Parameters<typeof status>[0]) {
|
||||
@@ -66,3 +66,202 @@ describe('BlogEntry MFM rendering', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlogEntry Heleneposting', () => {
|
||||
it('replaces only the displayed author name and avatar when enabled', () => {
|
||||
const original = account({
|
||||
acct: 'actual-author',
|
||||
display_name: 'Actual Author',
|
||||
avatar: 'https://example.test/actual.png',
|
||||
avatar_static: 'https://example.test/actual.png',
|
||||
})
|
||||
const services = testServices({
|
||||
preferences: preferences({ heleneposting: true }),
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: {
|
||||
status: status({
|
||||
account: original,
|
||||
content: '<p>Signed through <em>HTML</em><br>— Helene</p>',
|
||||
}),
|
||||
},
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.container.querySelector('.blog-entry-author')).toHaveAttribute(
|
||||
'href',
|
||||
'#/@actual-author',
|
||||
)
|
||||
expect(view.queryByText('Actual Author')).not.toBeInTheDocument()
|
||||
expect(view.container.querySelector('.blog-entry-avatar img')).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('helene'),
|
||||
)
|
||||
expect(view.getByText('@actual-author@example.test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlogEntry quote posts', () => {
|
||||
it('renders a Pleroma quote and links to quote composition and the quote list', () => {
|
||||
const quoted = status({
|
||||
id: 'quoted-entry',
|
||||
content: '<p>Quoted words</p>',
|
||||
account: account({ id: 'quoted-author', display_name: 'Quoted Author' }),
|
||||
})
|
||||
const outer = status({
|
||||
id: 'outer-entry',
|
||||
content: '<p>My commentary</p>',
|
||||
quotes_count: 3,
|
||||
pleroma: { quote: quoted, quote_id: quoted.id, quote_visible: true },
|
||||
})
|
||||
const services = testServices({
|
||||
session: session({ signedIn: true, token: 'token', me: account() }),
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: { status: outer },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText('Quoted words')).toBeInTheDocument()
|
||||
expect(view.getByRole('link', { name: 'Quote' })).toHaveAttribute(
|
||||
'href',
|
||||
'#/compose?quote=outer-entry',
|
||||
)
|
||||
expect(view.getByRole('link', { name: 'Quotes (3)' })).toHaveAttribute(
|
||||
'href',
|
||||
'#/blog/outer-entry/quotes',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a Mastodon quote authorization placeholder without leaking content', () => {
|
||||
const hidden = status({ id: 'hidden-quote', content: '<p>Do not display me</p>' })
|
||||
const view = renderEntry({
|
||||
quote: { state: 'blocked_account', quoted_status: hidden },
|
||||
})
|
||||
|
||||
expect(view.getByText('The quoted account is blocked.')).toBeInTheDocument()
|
||||
expect(view.queryByText('Do not display me')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer quoting when Mastodon says the viewer is denied', () => {
|
||||
const view = renderEntry({
|
||||
quote_approval: {
|
||||
automatic: [],
|
||||
manual: [],
|
||||
current_user: 'denied',
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.queryByRole('link', { name: 'Quote' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlogEntry emoji reactions', () => {
|
||||
it('collapses the reaction bar when the entry has no reactions', () => {
|
||||
const view = renderEntry({ pleroma: { emoji_reactions: [] } })
|
||||
|
||||
expect(view.queryByLabelText('Emoji reactions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows counts for Unicode and custom Pleroma/Akkoma reactions', () => {
|
||||
const view = renderEntry({
|
||||
pleroma: {
|
||||
emoji_reactions: [
|
||||
{ name: '🎉', count: 2, me: false },
|
||||
{
|
||||
name: 'party_blob@remote.example',
|
||||
count: 3,
|
||||
me: true,
|
||||
url: 'https://cdn.example/emoji/party_blob.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.getByLabelText('Emoji reactions')).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByRole('button', { name: 'Add 🎉 reaction, 2 reactions' }),
|
||||
).toHaveTextContent('2')
|
||||
expect(
|
||||
view.getByRole('button', {
|
||||
name: 'Remove :party_blob@remote.example: reaction, 3 reactions',
|
||||
}),
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(view.getByAltText(':party_blob@remote.example:')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example/emoji/party_blob.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('optimistically adds an existing reaction and applies the returned status', async () => {
|
||||
const original = status({
|
||||
pleroma: { emoji_reactions: [{ name: '🎉', count: 2, me: false }] },
|
||||
})
|
||||
const confirmed = status({
|
||||
pleroma: { emoji_reactions: [{ name: '🎉', count: 3, me: true }] },
|
||||
})
|
||||
const onupdate = vi.fn()
|
||||
const setEmojiReaction = vi.fn().mockResolvedValue(confirmed)
|
||||
const services = testServices({
|
||||
session: session({ signedIn: true, me: account() }),
|
||||
endpoints: { setEmojiReaction },
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: { status: original, onupdate },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(
|
||||
view.getByRole('button', { name: 'Add 🎉 reaction, 2 reactions' }),
|
||||
)
|
||||
|
||||
expect(setEmojiReaction).toHaveBeenCalledWith(
|
||||
services.session.api,
|
||||
original.id,
|
||||
'🎉',
|
||||
true,
|
||||
)
|
||||
expect(onupdate.mock.calls[0][0].pleroma?.emoji_reactions).toEqual([
|
||||
{ name: '🎉', count: 3, me: true },
|
||||
])
|
||||
await waitFor(() => expect(onupdate).toHaveBeenLastCalledWith(confirmed))
|
||||
})
|
||||
|
||||
it('removes the viewer’s existing custom reaction', async () => {
|
||||
const customReaction = {
|
||||
name: 'party_blob@remote.example',
|
||||
count: 1,
|
||||
me: true,
|
||||
url: 'https://cdn.example/emoji/party_blob.png',
|
||||
}
|
||||
const original = status({
|
||||
pleroma: { emoji_reactions: [customReaction] },
|
||||
})
|
||||
const confirmed = status({ pleroma: { emoji_reactions: [] } })
|
||||
const onupdate = vi.fn()
|
||||
const setEmojiReaction = vi.fn().mockResolvedValue(confirmed)
|
||||
const services = testServices({
|
||||
session: session({ signedIn: true, me: account() }),
|
||||
endpoints: { setEmojiReaction },
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: { status: original, onupdate },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(
|
||||
view.getByRole('button', {
|
||||
name: 'Remove :party_blob@remote.example: reaction, 1 reaction',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setEmojiReaction).toHaveBeenCalledWith(
|
||||
services.session.api,
|
||||
original.id,
|
||||
customReaction.name,
|
||||
false,
|
||||
)
|
||||
expect(onupdate.mock.calls[0][0].pleroma?.emoji_reactions).toEqual([])
|
||||
await waitFor(() => expect(onupdate).toHaveBeenLastCalledWith(confirmed))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,23 @@
|
||||
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
|
||||
/** Status attached as a structured quote. */
|
||||
quote?: Status | null
|
||||
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
||||
initialText?: string
|
||||
initialVisibility?: StatusVisibility
|
||||
@@ -23,6 +36,7 @@
|
||||
|
||||
let {
|
||||
inReplyTo = null,
|
||||
quote = null,
|
||||
initialText = '',
|
||||
initialVisibility = 'public',
|
||||
placeholder = 'What are you up to?',
|
||||
@@ -33,6 +47,7 @@
|
||||
const { endpoints, session } = useAppServices()
|
||||
// Seeded once from the prop; afterwards the textarea owns the value.
|
||||
let text = $state(untrack(() => initialText))
|
||||
let quotedStatus = $state<Status | null>(untrack(() => quote))
|
||||
let warning = $state('')
|
||||
let showWarning = $state(false)
|
||||
let visibility = $state<StatusVisibility>(
|
||||
@@ -73,7 +88,7 @@
|
||||
remaining >= 0 &&
|
||||
pollValid &&
|
||||
!(showPoll && attachments.length > 0) &&
|
||||
(text.trim().length > 0 || attachments.length > 0),
|
||||
(text.trim().length > 0 || attachments.length > 0 || quotedStatus !== null),
|
||||
)
|
||||
|
||||
const POLL_DURATION_PRESETS = [
|
||||
@@ -201,6 +216,7 @@
|
||||
multiple: pollMultiple,
|
||||
}
|
||||
: undefined,
|
||||
quoted_status_id: quotedStatus?.id,
|
||||
})
|
||||
text = ''
|
||||
warning = ''
|
||||
@@ -209,6 +225,7 @@
|
||||
showPoll = false
|
||||
pollOptions = ['', '']
|
||||
pollMultiple = false
|
||||
quotedStatus = null
|
||||
onposted?.(created)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||
@@ -250,6 +267,35 @@
|
||||
|
||||
{#if session.signedIn}
|
||||
<form class="composer" onsubmit={submit} use:composerInteractions>
|
||||
{#if quotedStatus}
|
||||
<div class="composer-quote">
|
||||
<p class="composer-quote-label">
|
||||
Quoting this entry
|
||||
<button
|
||||
type="button"
|
||||
class="link-button"
|
||||
aria-label="Remove quoted entry"
|
||||
onclick={() => (quotedStatus = null)}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</p>
|
||||
{#if quotedStatus.quote_approval?.current_user === 'manual'}
|
||||
<p class="composer-quote-notice">
|
||||
The original author will need to approve this quote before it appears.
|
||||
</p>
|
||||
{/if}
|
||||
<QuoteCard
|
||||
quote={{
|
||||
state: 'accepted',
|
||||
status: quotedStatus,
|
||||
id: quotedStatus.id,
|
||||
url: quotedStatus.url ?? quotedStatus.uri,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
@@ -274,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"
|
||||
@@ -346,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}
|
||||
|
||||
@@ -25,6 +25,31 @@ describe('Composer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('can submit a structured quote without commentary', async () => {
|
||||
const quoted = status({
|
||||
id: 'quoted-entry',
|
||||
content: '<p>The original thought</p>',
|
||||
})
|
||||
const postStatus = vi.fn().mockResolvedValue(status({ id: 'new-quote' }))
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { postStatus },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
props: { quote: quoted, submitLabel: 'Post Quote' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText('The original thought')).toBeInTheDocument()
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Post Quote' }))
|
||||
|
||||
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||
expect(postStatus.mock.calls[0][1]).toMatchObject({
|
||||
status: '',
|
||||
quoted_status_id: 'quoted-entry',
|
||||
})
|
||||
})
|
||||
|
||||
it('composes a multiple-choice poll without a backend', async () => {
|
||||
const postStatus = vi.fn().mockResolvedValue(status())
|
||||
const services = testServices({
|
||||
@@ -158,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,131 @@
|
||||
<script lang="ts">
|
||||
import type { EmojiReaction, Status } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { safeCustomEmojiUrl } from '$lib/util/html'
|
||||
|
||||
interface Props {
|
||||
status: Status
|
||||
onupdate?: (status: Status) => void
|
||||
}
|
||||
|
||||
let { status, onupdate }: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const reactions = $derived(
|
||||
(status.pleroma?.emoji_reactions ?? []).filter(
|
||||
(reaction) => reaction.name.trim().length > 0 && reaction.count > 0,
|
||||
),
|
||||
)
|
||||
|
||||
let busyEmoji = $state<string | null>(null)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function customLabel(reaction: EmojiReaction): string {
|
||||
return `:${reaction.name}:`
|
||||
}
|
||||
|
||||
function accessibleLabel(reaction: EmojiReaction): string {
|
||||
const action = reaction.me ? 'Remove' : 'Add'
|
||||
const emoji = reaction.url ? customLabel(reaction) : reaction.name
|
||||
const countLabel = reaction.count === 1 ? '1 reaction' : `${reaction.count} reactions`
|
||||
return `${action} ${emoji} reaction, ${countLabel}`
|
||||
}
|
||||
|
||||
function withReactions(base: Status, next: EmojiReaction[]): Status {
|
||||
return {
|
||||
...base,
|
||||
pleroma: {
|
||||
...(base.pleroma ?? {}),
|
||||
emoji_reactions: next,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticReactions(
|
||||
source: EmojiReaction[],
|
||||
selected: EmojiReaction,
|
||||
on: boolean,
|
||||
): EmojiReaction[] {
|
||||
return source
|
||||
.map((reaction) => {
|
||||
if (reaction.name !== selected.name) return reaction
|
||||
return {
|
||||
...reaction,
|
||||
count: Math.max(0, reaction.count + (on ? 1 : -1)),
|
||||
me: on,
|
||||
}
|
||||
})
|
||||
.filter((reaction) => reaction.count > 0)
|
||||
}
|
||||
|
||||
async function toggle(reaction: EmojiReaction): Promise<void> {
|
||||
if (!session.signedIn || busyEmoji) return
|
||||
|
||||
const before = status
|
||||
const on = !reaction.me
|
||||
busyEmoji = reaction.name
|
||||
error = null
|
||||
onupdate?.(
|
||||
withReactions(
|
||||
before,
|
||||
optimisticReactions(before.pleroma?.emoji_reactions ?? [], reaction, on),
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
const updated = await endpoints.setEmojiReaction(
|
||||
session.api,
|
||||
status.id,
|
||||
reaction.name,
|
||||
on,
|
||||
)
|
||||
onupdate?.(updated)
|
||||
} catch (cause) {
|
||||
onupdate?.(before)
|
||||
error =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Could not save that emoji reaction.'
|
||||
} finally {
|
||||
busyEmoji = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if reactions.length > 0}
|
||||
<div class="emoji-reaction-bar" aria-label="Emoji reactions">
|
||||
{#each reactions as reaction (reaction.url ?? reaction.name)}
|
||||
{@const imageUrl = reaction.url ? safeCustomEmojiUrl(reaction.url) : null}
|
||||
<button
|
||||
type="button"
|
||||
class="emoji-reaction"
|
||||
class:emoji-reaction--mine={reaction.me}
|
||||
data-custom={imageUrl ? 'true' : 'false'}
|
||||
aria-label={accessibleLabel(reaction)}
|
||||
aria-pressed={reaction.me ? 'true' : 'false'}
|
||||
title={session.signedIn
|
||||
? accessibleLabel(reaction)
|
||||
: 'Sign in to react with this emoji'}
|
||||
disabled={!session.signedIn || busyEmoji !== null}
|
||||
onclick={() => void toggle(reaction)}
|
||||
>
|
||||
{#if imageUrl}
|
||||
<img
|
||||
class="emoji-reaction-image"
|
||||
src={imageUrl}
|
||||
alt={customLabel(reaction)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<span class="emoji-reaction-glyph" aria-hidden="true">{reaction.name}</span>
|
||||
{/if}
|
||||
<span class="emoji-reaction-count">{reaction.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error-note emoji-reaction-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,19 @@
|
||||
<script lang="ts">
|
||||
/** Mastodon/Pleroma poll choices, voting and results. */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import type { CustomEmoji, Poll } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
poll: Poll
|
||||
emojis?: CustomEmoji[]
|
||||
authorId?: string
|
||||
onupdate?: (poll: Poll) => void
|
||||
}
|
||||
|
||||
let { poll, authorId, onupdate }: Props = $props()
|
||||
let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
|
||||
const { endpoints, session } = useAppServices()
|
||||
|
||||
function pollSignature(value: Poll): string {
|
||||
@@ -113,7 +115,7 @@
|
||||
{#if currentPoll.own_votes?.includes(index)}
|
||||
<span class="poll-own-vote" aria-label="Your vote">✓</span>
|
||||
{/if}
|
||||
{option.title}
|
||||
<EmojiText text={option.title} {emojis} />
|
||||
</span>
|
||||
<span class="poll-option-share">
|
||||
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
|
||||
@@ -137,7 +139,7 @@
|
||||
{:else}
|
||||
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
|
||||
{/if}
|
||||
<span>{option.title}</span>
|
||||
<EmojiText text={option.title} {emojis} />
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
@@ -91,4 +91,33 @@ describe('PollView', () => {
|
||||
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
|
||||
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom emoji in poll choices', () => {
|
||||
const view = render(PollView, {
|
||||
props: {
|
||||
poll: poll({
|
||||
options: [
|
||||
{ title: 'Choose :blobcat:', votes_count: null },
|
||||
{ title: 'No thanks', votes_count: null },
|
||||
],
|
||||
emojis: [
|
||||
{
|
||||
shortcode: 'blobcat',
|
||||
url: 'https://cdn.example/blobcat.png',
|
||||
static_url: 'https://cdn.example/blobcat.png',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
authorId: 'someone-else',
|
||||
},
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
|
||||
expect(view.getByAltText(':blobcat:')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example/blobcat.png',
|
||||
)
|
||||
expect(view.getByRole('radio', { name: 'Choose :blobcat:' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
/** A non-recursive embedded quote, with Mastodon's authorization states. */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import type { QuoteReference } from '$lib/util/status'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import { accountForStatus } from '$lib/util/heleneposting'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
import MfmContent from '../common/MfmContent.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
|
||||
interface Props {
|
||||
quote: QuoteReference
|
||||
}
|
||||
|
||||
let { quote }: Props = $props()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
let loadedStatus = $state<Status | null>(untrack(() => quote.status))
|
||||
let loading = $state(false)
|
||||
let loadError = $state(false)
|
||||
let loadGeneration = 0
|
||||
const status = $derived((loadedStatus ?? quote.status)?.reblog ?? loadedStatus ?? quote.status)
|
||||
const author = $derived(status ? accountForStatus(status, preferences.heleneposting) : null)
|
||||
|
||||
$effect(() => {
|
||||
const supplied = quote.status
|
||||
const state = quote.state
|
||||
const id = quote.id
|
||||
const generation = ++loadGeneration
|
||||
loadedStatus = supplied
|
||||
loading = false
|
||||
loadError = false
|
||||
if (supplied || state !== 'accepted' || !id) return
|
||||
loading = true
|
||||
untrack(() => void loadShallowQuote(id, generation))
|
||||
return () => {
|
||||
if (generation === loadGeneration) loadGeneration += 1
|
||||
}
|
||||
})
|
||||
|
||||
async function loadShallowQuote(id: string, generation: number): Promise<void> {
|
||||
try {
|
||||
const found = await endpoints.fetchStatus(session.api, id)
|
||||
if (generation === loadGeneration && quote.id === id) loadedStatus = found
|
||||
} catch {
|
||||
if (generation === loadGeneration) loadError = true
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const STATE_MESSAGE: Record<string, string> = {
|
||||
pending: 'This quote is awaiting approval.',
|
||||
rejected: 'The author did not approve this quote.',
|
||||
revoked: 'The author revoked this quote.',
|
||||
deleted: 'The quoted entry was deleted.',
|
||||
unauthorized: 'The quoted entry is not available to you.',
|
||||
blocked_account: 'The quoted account is blocked.',
|
||||
blocked_domain: 'The quoted account’s server is blocked.',
|
||||
muted_account: 'The quoted account is muted.',
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="quote-card" data-quote-state={quote.state}>
|
||||
{#if loading}
|
||||
<p class="quote-card-placeholder">Loading quoted entry…</p>
|
||||
{:else if status}
|
||||
<header class="quote-card-header">
|
||||
<Avatar account={author ?? status.account} plain class="quote-card-avatar" />
|
||||
<span class="quote-card-byline">
|
||||
<a class="quote-card-author" href={profilePath(status.account)}>
|
||||
<EmojiText
|
||||
text={displayNameOf(author ?? status.account)}
|
||||
emojis={status.account.emojis}
|
||||
/>
|
||||
</a>
|
||||
<span class="quote-card-handle">{fullHandle(status.account, session.host)}</span>
|
||||
</span>
|
||||
<a class="quote-card-date" href={`#/blog/${status.id}`}>{stampDate(status.created_at)}</a>
|
||||
</header>
|
||||
|
||||
<div class="quote-card-body">
|
||||
{#if status.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">
|
||||
<EmojiText text={status.spoiler_text} emojis={status.emojis} />
|
||||
</summary>
|
||||
<MfmContent
|
||||
html={status.content}
|
||||
emojis={status.emojis}
|
||||
mentions={status.mentions}
|
||||
tags={status.tags}
|
||||
lang={status.language}
|
||||
/>
|
||||
{#if status.media_attachments.length > 0}
|
||||
<Attachments
|
||||
attachments={status.media_attachments}
|
||||
emojis={status.emojis}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
{/if}
|
||||
</details>
|
||||
{:else}
|
||||
<MfmContent
|
||||
html={status.content}
|
||||
emojis={status.emojis}
|
||||
mentions={status.mentions}
|
||||
tags={status.tags}
|
||||
lang={status.language}
|
||||
/>
|
||||
{#if status.media_attachments.length > 0}
|
||||
<Attachments
|
||||
attachments={status.media_attachments}
|
||||
emojis={status.emojis}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if status.poll}
|
||||
<PollView
|
||||
poll={status.poll}
|
||||
emojis={status.poll.emojis?.length ? status.poll.emojis : status.emojis}
|
||||
authorId={status.account.id}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if status.card && status.media_attachments.length === 0}
|
||||
<PreviewCardView card={status.card} />
|
||||
{/if}
|
||||
|
||||
{#if status.quote || status.pleroma?.quote}
|
||||
<p class="quote-card-nested">
|
||||
<a href={`#/blog/${status.id}`}>View the nested quote</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="quote-card-placeholder">
|
||||
{loadError
|
||||
? 'The quoted entry could not be loaded.'
|
||||
: STATE_MESSAGE[quote.state] ?? 'The quoted entry is unavailable.'}
|
||||
{#if quote.url}
|
||||
<a href={quote.url} target="_blank" rel="noopener noreferrer">View original reference</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { session, status, testServices } from '$test/fixtures'
|
||||
import QuoteCard from './QuoteCard.svelte'
|
||||
|
||||
describe('QuoteCard', () => {
|
||||
it('loads a Mastodon shallow quote by its status ID', async () => {
|
||||
const fetchStatus = vi.fn().mockResolvedValue(
|
||||
status({ id: 'shallow-target', content: '<p>Fetched shallow quote</p>' }),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session(),
|
||||
endpoints: { fetchStatus },
|
||||
})
|
||||
const view = render(QuoteCard, {
|
||||
props: {
|
||||
quote: {
|
||||
state: 'accepted',
|
||||
status: null,
|
||||
id: 'shallow-target',
|
||||
url: null,
|
||||
},
|
||||
},
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(await view.findByText('Fetched shallow quote')).toBeInTheDocument()
|
||||
expect(fetchStatus).toHaveBeenCalledWith(services.session.api, 'shallow-target')
|
||||
})
|
||||
})
|
||||
@@ -7,14 +7,18 @@
|
||||
import type { ApiClient } from '$lib/api/client'
|
||||
import type { Notification } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { accountForNotification } from '$lib/util/heleneposting'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import {
|
||||
NOTIFICATION_DISMISS_AFTER_MS,
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
NotificationTracker,
|
||||
emojiForNotification,
|
||||
presentNotification,
|
||||
type NotificationPresentation,
|
||||
} from '$lib/notifications'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
pollIntervalMs?: number
|
||||
@@ -33,7 +37,7 @@
|
||||
maxToasts = 4,
|
||||
}: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
const tracker = new NotificationTracker()
|
||||
const dismissTimers = new Map<string, number>()
|
||||
|
||||
@@ -138,6 +142,8 @@
|
||||
|
||||
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
|
||||
{#each toasts as toast (toast.notification.id)}
|
||||
{@const reaction = emojiForNotification(toast.notification)}
|
||||
{@const actor = accountForNotification(toast.notification, preferences.heleneposting)}
|
||||
<a
|
||||
class="notification-toast"
|
||||
href={toast.presentation.href}
|
||||
@@ -145,14 +151,26 @@
|
||||
data-account={toast.notification.account.acct}
|
||||
onclick={() => dismiss(toast.notification.id)}
|
||||
>
|
||||
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
|
||||
<Avatar account={actor} plain class="notification-toast-avatar" />
|
||||
<span class="notification-toast-body">
|
||||
<span class="notification-toast-message">
|
||||
<strong>{toast.presentation.actor}</strong>
|
||||
<strong>
|
||||
<EmojiText
|
||||
text={displayNameOf(actor)}
|
||||
emojis={toast.notification.account.emojis}
|
||||
/>
|
||||
</strong>
|
||||
{toast.presentation.message}
|
||||
{#if reaction}
|
||||
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
|
||||
{/if}
|
||||
</span>
|
||||
{#if toast.presentation.excerpt}
|
||||
<span class="notification-toast-excerpt">{toast.presentation.excerpt}</span>
|
||||
<EmojiText
|
||||
class="notification-toast-excerpt"
|
||||
text={toast.presentation.excerpt}
|
||||
emojis={toast.notification.status?.emojis}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -71,4 +71,37 @@ describe('NotificationToasts', () => {
|
||||
})
|
||||
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a safely sized custom emoji from a reaction notification', async () => {
|
||||
vi.useFakeTimers()
|
||||
const oldNotification = notification({ id: 'old-notification' })
|
||||
const reaction = notification({
|
||||
id: 'reaction-notification',
|
||||
type: 'pleroma:emoji_reaction',
|
||||
emoji: ':dinosaur:',
|
||||
emoji_url: 'https://cdn.example/dinosaur.gif',
|
||||
})
|
||||
const fetchNotifications = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [oldNotification], links: {} })
|
||||
.mockResolvedValueOnce({ items: [reaction, oldNotification], links: {} })
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { fetchNotifications },
|
||||
})
|
||||
const view = render(NotificationToasts, {
|
||||
props: { pollIntervalMs: 1_000 },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
|
||||
expect(view.getByAltText(':dinosaur:')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example/dinosaur.gif',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#/browse">Browse</a>
|
||||
<a href="#/search">Search</a>
|
||||
<a href="#/settings">Settings</a>
|
||||
<a href="#/about">About plspace</a>
|
||||
{#if domain}
|
||||
<a href={`https://${session.host}/about`} target="_blank" rel="noopener noreferrer">About this server</a>
|
||||
{/if}
|
||||
|
||||
@@ -74,9 +74,7 @@
|
||||
</form>
|
||||
|
||||
<p class="site-account-links">
|
||||
<a href="#/settings">Settings</a>
|
||||
{#if session.signedIn}
|
||||
<span aria-hidden="true">|</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-button site-header-logout"
|
||||
@@ -85,7 +83,6 @@
|
||||
LogOut
|
||||
</button>
|
||||
{:else}
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="#/login">LogIn</a>
|
||||
{/if}
|
||||
</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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Escaped plain text with server-declared `:shortcode:` values replaced by
|
||||
* custom emoji. Use this for names, content warnings, poll choices and other
|
||||
* API strings that are not HTML.
|
||||
*/
|
||||
import type { CustomEmoji } from '$lib/api/types'
|
||||
import { renderEmojiText } from '$lib/util/html'
|
||||
|
||||
interface Props {
|
||||
text: string | null | undefined
|
||||
emojis?: CustomEmoji[]
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { text, emojis, class: extraClass = '' }: Props = $props()
|
||||
const rendered = $derived(renderEmojiText(text ?? '', emojis))
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderEmojiText -->
|
||||
<span class="emoji-text {extraClass}">{@html rendered}</span>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import EmojiText from './EmojiText.svelte'
|
||||
|
||||
const wideEmoji = {
|
||||
shortcode: 'wide',
|
||||
url: 'https://cdn.example/wide.png',
|
||||
static_url: 'https://cdn.example/wide.png',
|
||||
visible_in_picker: true,
|
||||
}
|
||||
|
||||
describe('EmojiText', () => {
|
||||
it('escapes plain text and replaces declared custom emoji', () => {
|
||||
const view = render(EmojiText, {
|
||||
props: {
|
||||
text: '<b>unsafe</b> :wide:',
|
||||
emojis: [wideEmoji],
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.getByText('<b>unsafe</b>')).toBeInTheDocument()
|
||||
expect(view.queryByText('unsafe', { selector: 'b' })).not.toBeInTheDocument()
|
||||
expect(view.getByAltText(':wide:')).toHaveAttribute('src', wideEmoji.url)
|
||||
expect(view.getByAltText(':wide:')).toHaveClass('custom-emoji')
|
||||
})
|
||||
|
||||
it('does not inject emoji images from unsafe URL schemes', () => {
|
||||
const view = render(EmojiText, {
|
||||
props: {
|
||||
text: ':wide:',
|
||||
emojis: [{ ...wideEmoji, url: 'data:image/svg+xml,<svg></svg>' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(view.getByText(':wide:')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
* it intentionally does not parse raw `$[...]` source syntax.
|
||||
*/
|
||||
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { renderMfmHtml } from '$lib/util/mfm'
|
||||
|
||||
interface Props {
|
||||
@@ -33,8 +34,17 @@
|
||||
scale = false,
|
||||
}: Props = $props()
|
||||
|
||||
const { preferences } = useAppServices()
|
||||
const rendered = $derived(
|
||||
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }),
|
||||
renderMfmHtml(html, {
|
||||
emojis,
|
||||
mentions,
|
||||
tags,
|
||||
inline,
|
||||
pause,
|
||||
scale,
|
||||
greentext: preferences.greentexting,
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { preferences, testServices } from '$test/fixtures'
|
||||
import MfmContent from './MfmContent.svelte'
|
||||
|
||||
function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
|
||||
@@ -70,4 +72,53 @@ describe('MfmContent', () => {
|
||||
'https://example.test/emoji/party.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('greentexts visual lines, including prose after a leading mention', () => {
|
||||
const view = render(MfmContent, {
|
||||
props: {
|
||||
html: [
|
||||
'<p>>first line<br>',
|
||||
'<a class="mention" href="https://example.test/@alice">@alice</a> >mentioned line<br>',
|
||||
'ordinary line</p>',
|
||||
].join(''),
|
||||
},
|
||||
context: new Map([
|
||||
[
|
||||
APP_SERVICES,
|
||||
testServices({
|
||||
preferences: preferences({ greentexting: true }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const green = view.container.querySelectorAll('.greentext')
|
||||
expect(green).toHaveLength(2)
|
||||
expect(green[0]).toHaveTextContent('>first line')
|
||||
expect(green[1]).toHaveTextContent('@alice >mentioned line')
|
||||
expect(view.getByText('ordinary line')).not.toHaveClass('greentext')
|
||||
})
|
||||
|
||||
it('does not greentext blockquotes, code, lists, or disabled content', () => {
|
||||
const enabled = render(MfmContent, {
|
||||
props: {
|
||||
html: '<blockquote>>quote</blockquote><pre>>code</pre><ul><li>>item</li></ul>',
|
||||
},
|
||||
context: new Map([
|
||||
[
|
||||
APP_SERVICES,
|
||||
testServices({
|
||||
preferences: preferences({ greentexting: true }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
})
|
||||
expect(enabled.container.querySelector('.greentext')).not.toBeInTheDocument()
|
||||
|
||||
const disabled = render(MfmContent, {
|
||||
props: { html: '<p>>plain</p>' },
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
expect(disabled.container.querySelector('.greentext')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
* plain no chrome, just the heading
|
||||
*/
|
||||
import type { Snippet } from 'svelte'
|
||||
import type { CustomEmoji } from '$lib/api/types'
|
||||
import EmojiText from './EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
/** Custom emoji available to user-derived titles. */
|
||||
titleEmojis?: CustomEmoji[]
|
||||
variant?: 'panel' | 'band' | 'plain'
|
||||
/** Right-aligned link in the caption bar, e.g. "[view all]". */
|
||||
action?: Snippet
|
||||
@@ -23,6 +27,7 @@
|
||||
|
||||
let {
|
||||
title,
|
||||
titleEmojis,
|
||||
variant = 'panel',
|
||||
action,
|
||||
flush = false,
|
||||
@@ -38,7 +43,7 @@
|
||||
<section class="module {variantClass} {extraClass}" data-variant={variant}>
|
||||
{#if title}
|
||||
<h2 class="module-header">
|
||||
<span class="module-header-title">{title}</span>
|
||||
<EmojiText class="module-header-title" text={title} emojis={titleEmojis} />
|
||||
{#if action}
|
||||
<span class="module-header-action">{@render action()}</span>
|
||||
{/if}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
@@ -73,7 +74,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Module title={`Contacting ${firstName}`}>
|
||||
<Module title={`Contacting ${firstName}`} titleEmojis={account.emojis}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
@@ -137,6 +138,8 @@
|
||||
</ul>
|
||||
|
||||
{#if relationship?.followed_by && !isSelf}
|
||||
<p class="contact-note muted">{firstName} has you on their friends list.</p>
|
||||
<p class="contact-note muted">
|
||||
<EmojiText text={firstName} emojis={account.emojis} /> has you on their friends list.
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
@@ -7,23 +7,28 @@
|
||||
* an unverified link that looks verified is a phishing surface.
|
||||
*/
|
||||
import type { ProfileField } from '$lib/util/profile'
|
||||
import type { CustomEmoji } from '$lib/api/types'
|
||||
import Module from '../common/Module.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
fields: ProfileField[]
|
||||
emojis?: CustomEmoji[]
|
||||
}
|
||||
|
||||
let { title, fields }: Props = $props()
|
||||
let { title, fields, emojis }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if fields.length > 0}
|
||||
<Module {title} flush>
|
||||
<Module {title} titleEmojis={emojis} flush>
|
||||
<table class="data-table details-table">
|
||||
<tbody>
|
||||
{#each fields as field, index (`${field.name}:${index}`)}
|
||||
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
||||
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
||||
<th class="data-table-label details-label" scope="row">
|
||||
<EmojiText text={field.name} {emojis} />
|
||||
</th>
|
||||
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
||||
{#if field.verified}
|
||||
<span class="verified-mark" title="Ownership of this link is verified">✓</span>
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
* accounts often return an empty list rather than an error, so the count and
|
||||
* the grid are allowed to disagree; the count is authoritative.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import type { Account, CustomEmoji } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, 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 {
|
||||
title: string
|
||||
/** The subject, used in "Tom has 527 friends." */
|
||||
ownerName: string
|
||||
ownerEmojis?: CustomEmoji[]
|
||||
friends: Account[]
|
||||
total: number
|
||||
viewAllHref: string
|
||||
@@ -29,6 +31,7 @@
|
||||
let {
|
||||
title,
|
||||
ownerName,
|
||||
ownerEmojis,
|
||||
friends,
|
||||
total,
|
||||
viewAllHref,
|
||||
@@ -39,7 +42,7 @@
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Module {title} variant="band">
|
||||
<Module {title} titleEmojis={ownerEmojis} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={viewAllHref}>[view all]</a>
|
||||
{/snippet}
|
||||
@@ -47,10 +50,13 @@
|
||||
<!-- Never render a withheld count as "0 friends" — that reports a privacy
|
||||
setting as a fact about the person. -->
|
||||
{#if countHidden}
|
||||
<p class="friend-count">{ownerName} keeps their friend count private.</p>
|
||||
<p class="friend-count">
|
||||
<EmojiText text={ownerName} emojis={ownerEmojis} /> keeps their friend count private.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="friend-count">
|
||||
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
|
||||
<EmojiText text={ownerName} emojis={ownerEmojis} /> has
|
||||
<span class="friend-count-value">{formatCount(total)}</span>
|
||||
friend{total === 1 ? '' : 's'}.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -66,7 +72,11 @@
|
||||
{#each friends as friend (friend.id)}
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<EmojiText
|
||||
class="friend-card-name"
|
||||
text={displayNameOf(friend)}
|
||||
emojis={friend.emojis}
|
||||
/>
|
||||
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -6,18 +6,20 @@
|
||||
* Mastodon account gets a compact box rather than six empty rows.
|
||||
*/
|
||||
import type { InterestEntry } from '$lib/util/profile'
|
||||
import type { CustomEmoji } from '$lib/api/types'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
interests: InterestEntry[]
|
||||
emojis?: CustomEmoji[]
|
||||
}
|
||||
|
||||
let { title, interests }: Props = $props()
|
||||
let { title, interests, emojis }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if interests.length > 0}
|
||||
<Module {title} flush>
|
||||
<Module {title} titleEmojis={emojis} flush>
|
||||
<table class="data-table interests-table">
|
||||
<tbody>
|
||||
{#each interests as entry, index (`${entry.row}:${index}`)}
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
* stream. Videos/audio stay in the Blog; reposted pictures are not somebody's
|
||||
* own Pics.
|
||||
*/
|
||||
import type { MediaAttachment, Status } from '$lib/api/types'
|
||||
import type { CustomEmoji, MediaAttachment, Status } from '$lib/api/types'
|
||||
import type { Feed } from '$lib/stores/feed.svelte'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import Pager from '../common/Pager.svelte'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Status>
|
||||
ownerName: string
|
||||
ownerEmojis?: CustomEmoji[]
|
||||
}
|
||||
|
||||
interface Picture {
|
||||
@@ -22,7 +24,7 @@
|
||||
caption: string
|
||||
}
|
||||
|
||||
let { feed, ownerName }: Props = $props()
|
||||
let { feed, ownerName, ownerEmojis }: Props = $props()
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
const pictures = $derived.by<Picture[]>(() =>
|
||||
@@ -46,7 +48,8 @@
|
||||
</script>
|
||||
|
||||
<p class="pic-stream-intro">
|
||||
Pictures from {ownerName}'s Blog Entries. Click a picture to view the full-size original.
|
||||
Pictures from <EmojiText text={ownerName} emojis={ownerEmojis} />'s Blog Entries. Click a
|
||||
picture to view the full-size original.
|
||||
</p>
|
||||
|
||||
{#if !feed.initialized && feed.loading}
|
||||
@@ -97,10 +100,17 @@
|
||||
<figcaption class="pic-card-caption">
|
||||
{#if hidden}
|
||||
<span class="pic-card-description">
|
||||
{picture.status.spoiler_text || 'Sensitive picture'}
|
||||
<EmojiText
|
||||
text={picture.status.spoiler_text || 'Sensitive picture'}
|
||||
emojis={picture.status.emojis}
|
||||
/>
|
||||
</span>
|
||||
{:else if picture.caption}
|
||||
<span class="pic-card-description">{picture.caption}</span>
|
||||
<EmojiText
|
||||
class="pic-card-description"
|
||||
text={picture.caption}
|
||||
emojis={picture.status.emojis}
|
||||
/>
|
||||
{/if}
|
||||
<a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}>
|
||||
Posted {stampDate(picture.status.created_at)} · view entry
|
||||
@@ -115,6 +125,6 @@
|
||||
<Pager
|
||||
{feed}
|
||||
label="View More Pictures"
|
||||
emptyText={`${ownerName} hasn't posted any pictures yet.`}
|
||||
emptyText="There aren't any pictures here yet."
|
||||
endText={pictures.length > 0 ? 'That’s the whole picture stream.' : ''}
|
||||
/>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
*/
|
||||
import type { ProfileView } from '$lib/util/profile'
|
||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import EmojiText from '../common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView
|
||||
@@ -22,7 +22,6 @@
|
||||
|
||||
const { session } = useAppServices()
|
||||
const account = $derived(profile.account)
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
const handle = $derived(fullHandle(account, session.host))
|
||||
const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
|
||||
const photo = $derived(account.avatar || account.avatar_static)
|
||||
@@ -41,12 +40,16 @@
|
||||
</div>
|
||||
|
||||
<div class="profile-vitals-wrap">
|
||||
<p class="profile-headline">{profile.headline}</p>
|
||||
<p class="profile-headline">
|
||||
<EmojiText text={profile.headline} emojis={account.emojis} />
|
||||
</p>
|
||||
|
||||
<dl class="profile-vitals">
|
||||
{#if profile.gender}
|
||||
<dt>Gender</dt>
|
||||
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
|
||||
<dd class="profile-vital profile-vital--gender">
|
||||
<EmojiText text={profile.gender} emojis={account.emojis} />
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
{#if accountAge !== null}
|
||||
@@ -61,7 +64,9 @@
|
||||
|
||||
{#if profile.location}
|
||||
<dt>Location</dt>
|
||||
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
|
||||
<dd class="profile-vital profile-vital--location">
|
||||
<EmojiText text={profile.location} emojis={account.emojis} />
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
<dt>Last active</dt>
|
||||
@@ -78,7 +83,8 @@
|
||||
|
||||
{#if profile.mood}
|
||||
<p class="profile-mood">
|
||||
Mood: <span class="profile-mood-value">{profile.mood}</span>
|
||||
Mood:
|
||||
<EmojiText class="profile-mood-value" text={profile.mood} emojis={account.emojis} />
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -4,11 +4,19 @@
|
||||
* in a separate settings editor.
|
||||
*/
|
||||
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 { useAppServices } from '$lib/app-services'
|
||||
import { profileFieldLimits } from '$lib/stores/theme.svelte'
|
||||
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 {
|
||||
id: number
|
||||
@@ -40,8 +48,14 @@
|
||||
}
|
||||
|
||||
const initial = untrack(() => session.me)
|
||||
const initialBio = parseTopEightText(initial?.source?.note ?? toPlainText(initial?.note ?? ''))
|
||||
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 actorType = $state<'Person' | 'Service' | 'Group'>(
|
||||
initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'),
|
||||
@@ -69,6 +83,12 @@
|
||||
const isEgregorosServer = $derived(isEgregoros(session.instance))
|
||||
const capabilities = $derived(publicProfileCapabilities(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 availablePublicFields = $derived(
|
||||
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
|
||||
@@ -82,7 +102,12 @@
|
||||
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 {
|
||||
if (!canAddField) return
|
||||
@@ -93,6 +118,49 @@
|
||||
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(
|
||||
kind: 'avatar' | 'header' | 'background',
|
||||
event: Event,
|
||||
@@ -118,8 +186,12 @@
|
||||
}
|
||||
|
||||
function resetFrom(account: CredentialAccount): void {
|
||||
const parsedBio = parseTopEightText(account.source?.note ?? toPlainText(account.note))
|
||||
displayName = account.display_name
|
||||
note = account.source?.note ?? toPlainText(account.note)
|
||||
note = parsedBio.bio
|
||||
topEightHandles = parsedBio.handles
|
||||
topEightQuery = ''
|
||||
topEightResults = []
|
||||
fields = publicFields(account)
|
||||
actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person')
|
||||
birthday = account.pleroma?.birthday ?? ''
|
||||
@@ -154,7 +226,7 @@
|
||||
: []
|
||||
const updated = await endpoints.updatePublicProfile(session.api, {
|
||||
displayName: displayName.trim(),
|
||||
note,
|
||||
note: savedNote,
|
||||
fields: capabilities.fields ? [...visible, ...hidden] : undefined,
|
||||
avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
|
||||
header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
|
||||
@@ -208,9 +280,84 @@
|
||||
<div class="field">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#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">
|
||||
<legend>Profile images</legend>
|
||||
|
||||
|
||||
@@ -136,6 +136,41 @@ describe('PublicProfileEditor', () => {
|
||||
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 () => {
|
||||
const me = credential()
|
||||
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>
|
||||
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiClient } from './client'
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchQuotes,
|
||||
postStatus,
|
||||
setEmojiReaction,
|
||||
uploadMedia,
|
||||
updateProfileFields,
|
||||
updatePublicProfile,
|
||||
votePoll,
|
||||
@@ -95,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) => {
|
||||
@@ -152,6 +188,86 @@ describe('poll endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('emoji reaction endpoints', () => {
|
||||
it.each([
|
||||
[true, 'PUT'],
|
||||
[false, 'DELETE'],
|
||||
] as const)('uses the Pleroma/Akkoma reaction endpoint (on=%s)', async (on, method) => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.method).toBe(method)
|
||||
return new Response(JSON.stringify({ id: 'status/one' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await setEmojiReaction(
|
||||
new ApiClient('example.test', 'token'),
|
||||
'status/one',
|
||||
'party_blob@remote.example',
|
||||
on,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/pleroma/statuses/status%2Fone/reactions/party_blob%40remote.example',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quote endpoints', () => {
|
||||
it('sends both current and legacy quote parameters when composing', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
status: 'Commentary',
|
||||
quoted_status_id: 'quoted/one',
|
||||
quote_id: 'quoted/one',
|
||||
})
|
||||
return new Response(JSON.stringify({ id: 'quote-1' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await postStatus(new ApiClient('example.test', 'token'), {
|
||||
status: 'Commentary',
|
||||
quoted_status_id: 'quoted/one',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the legacy Pleroma quote-list endpoint', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ id: 'quote-1' }]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const page = await fetchQuotes(new ApiClient('old-pleroma.example', 'token'), 'status/one', {
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
expect(page.items).toEqual([{ id: 'quote-1' }])
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://old-pleroma.example/api/v1/statuses/status%2Fone/quotes?limit=20',
|
||||
)
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
'https://old-pleroma.example/api/v1/pleroma/statuses/status%2Fone/quotes?limit=20',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNotifications', () => {
|
||||
it('defensively applies requested types when a server ignores the filter', async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -283,6 +283,7 @@ export interface ComposeOptions {
|
||||
expires_in: number
|
||||
multiple: boolean
|
||||
}
|
||||
quoted_status_id?: string
|
||||
}
|
||||
|
||||
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
||||
@@ -297,9 +298,29 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Sta
|
||||
if (options.media_ids?.length) body.media_ids = options.media_ids
|
||||
if (options.language) body.language = options.language
|
||||
if (options.poll) body.poll = options.poll
|
||||
if (options.quoted_status_id) {
|
||||
body.quoted_status_id = options.quoted_status_id
|
||||
// Older Pleroma/Akkoma releases predate the standardized Mastodon name.
|
||||
// Mastodon ignores unknown JSON keys, while those servers require this.
|
||||
body.quote_id = options.quoted_status_id
|
||||
}
|
||||
return api.post<Status>('/api/v1/statuses', body)
|
||||
}
|
||||
|
||||
export async function fetchQuotes(
|
||||
api: ApiClient,
|
||||
id: string,
|
||||
cursor: Cursor = {},
|
||||
): Promise<Page<Status>> {
|
||||
const encoded = encodeURIComponent(id)
|
||||
try {
|
||||
return await api.page<Status>(`/api/v1/statuses/${encoded}/quotes`, { ...cursor })
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof ApiError) || (cause.status !== 404 && cause.status !== 405)) throw cause
|
||||
return api.page<Status>(`/api/v1/pleroma/statuses/${encoded}/quotes`, { ...cursor })
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
|
||||
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
|
||||
}
|
||||
@@ -314,6 +335,26 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<S
|
||||
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove one of the reactions already present on a status.
|
||||
*
|
||||
* Pleroma and Akkoma share this endpoint. `emoji` can be a Unicode emoji, a
|
||||
* local custom shortcode, or the server-qualified `shortcode@host` returned in
|
||||
* `pleroma.emoji_reactions`.
|
||||
*/
|
||||
export async function setEmojiReaction(
|
||||
api: ApiClient,
|
||||
id: string,
|
||||
emoji: string,
|
||||
on: boolean,
|
||||
): Promise<Status> {
|
||||
const path =
|
||||
`/api/v1/pleroma/statuses/${encodeURIComponent(id)}` +
|
||||
`/reactions/${encodeURIComponent(emoji)}`
|
||||
const { data } = await api.raw<Status>(path, { method: on ? 'PUT' : 'DELETE' })
|
||||
return data
|
||||
}
|
||||
|
||||
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
||||
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
||||
}
|
||||
|
||||
+69
-1
@@ -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 {
|
||||
@@ -163,6 +169,47 @@ export interface Poll {
|
||||
own_votes?: number[]
|
||||
}
|
||||
|
||||
/** Pleroma/Akkoma's per-status emoji reaction summary. */
|
||||
export interface EmojiReaction {
|
||||
/** Unicode emoji, local custom shortcode, or `shortcode@remote.host`. */
|
||||
name: string
|
||||
count: number
|
||||
/** Whether the authenticated viewer contributed to this count. */
|
||||
me: boolean
|
||||
/** Present for custom emoji reactions. */
|
||||
url?: string | null
|
||||
/** Included by some Pleroma/Akkoma status renderers. */
|
||||
account_ids?: string[]
|
||||
/** Included by the expanded reactions endpoint, but usually absent on timelines. */
|
||||
accounts?: Account[]
|
||||
}
|
||||
|
||||
export type QuoteState =
|
||||
| 'pending'
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'revoked'
|
||||
| 'deleted'
|
||||
| 'unauthorized'
|
||||
| 'blocked_account'
|
||||
| 'blocked_domain'
|
||||
| 'muted_account'
|
||||
| string
|
||||
|
||||
/** Mastodon 4.4+ quote envelope. Pleroma places the Status under `pleroma.quote`. */
|
||||
export interface StatusQuote {
|
||||
state: QuoteState
|
||||
quoted_status?: Status | null
|
||||
/** Mastodon ShallowQuote form. */
|
||||
quoted_status_id?: string | null
|
||||
}
|
||||
|
||||
export interface QuoteApproval {
|
||||
automatic: string[]
|
||||
manual: string[]
|
||||
current_user: 'automatic' | 'manual' | 'denied' | 'unknown' | string
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
id: string
|
||||
uri: string
|
||||
@@ -183,6 +230,7 @@ export interface Status {
|
||||
replies_count: number
|
||||
reblogs_count: number
|
||||
favourites_count: number
|
||||
quotes_count?: number
|
||||
|
||||
media_attachments: MediaAttachment[]
|
||||
mentions: StatusMention[]
|
||||
@@ -190,6 +238,12 @@ export interface Status {
|
||||
emojis: CustomEmoji[]
|
||||
card?: PreviewCard | null
|
||||
poll?: Poll | null
|
||||
/** Mastodon quote envelope, or a raw quoted Status on a few compatible forks. */
|
||||
quote?: StatusQuote | Status | null
|
||||
quote_approval?: QuoteApproval | null
|
||||
/** Compatibility fields returned at top-level by some Pleroma-family forks. */
|
||||
quote_id?: string | null
|
||||
quote_url?: string | null
|
||||
application?: { name: string; website?: string | null } | null
|
||||
|
||||
reblog: Status | null
|
||||
@@ -205,6 +259,13 @@ export interface Status {
|
||||
conversation_id?: number
|
||||
content?: Record<string, string>
|
||||
spoiler_text?: Record<string, string>
|
||||
/** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */
|
||||
emoji_reactions?: EmojiReaction[]
|
||||
quote?: Status | null
|
||||
quote_id?: string | null
|
||||
quote_url?: string | null
|
||||
quote_visible?: boolean
|
||||
quotes_count?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +294,8 @@ export type NotificationType =
|
||||
| 'favourite'
|
||||
| 'poll'
|
||||
| 'update'
|
||||
| 'quote'
|
||||
| 'quoted_update'
|
||||
| 'admin.sign_up'
|
||||
| 'admin.report'
|
||||
| 'pleroma:emoji_reaction'
|
||||
@@ -244,6 +307,9 @@ export interface Notification {
|
||||
created_at: string
|
||||
account: Account
|
||||
status?: Status | null
|
||||
/** Pleroma/Akkoma emoji-reaction notification payload. */
|
||||
emoji?: string | null
|
||||
emoji_url?: string | null
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
@@ -284,6 +350,8 @@ export interface InstanceInfo {
|
||||
}
|
||||
accounts?: {
|
||||
max_profile_fields?: number
|
||||
/** Mastodon 4.6+ bio limit. */
|
||||
max_note_length?: number
|
||||
/** Pleroma v2 names. */
|
||||
profile_field_name_limit?: number
|
||||
profile_field_value_limit?: number
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as endpointImplementations from './api/endpoints'
|
||||
import { router as defaultRouter, type RouteMatch } from './router.svelte'
|
||||
import { session as defaultSession } from './stores/session.svelte'
|
||||
import { theme as defaultTheme } from './stores/theme.svelte'
|
||||
import { preferences as defaultPreferences } from './stores/preferences.svelte'
|
||||
|
||||
export interface SessionService {
|
||||
host: string
|
||||
@@ -45,10 +46,18 @@ export interface ThemeService {
|
||||
clearProfileCss(): void
|
||||
}
|
||||
|
||||
export interface PreferencesService {
|
||||
heleneposting: boolean
|
||||
greentexting: boolean
|
||||
setHeleneposting(enabled: boolean): void
|
||||
setGreentexting(enabled: boolean): void
|
||||
}
|
||||
|
||||
export interface AppServices {
|
||||
session: SessionService
|
||||
router: RouterService
|
||||
theme: ThemeService
|
||||
preferences: PreferencesService
|
||||
endpoints: typeof endpointImplementations
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ export const defaultAppServices: AppServices = {
|
||||
session: defaultSession,
|
||||
router: defaultRouter,
|
||||
theme: defaultTheme,
|
||||
preferences: defaultPreferences,
|
||||
endpoints: endpointImplementations,
|
||||
}
|
||||
|
||||
@@ -70,6 +80,7 @@ export interface AppServiceOverrides {
|
||||
session?: SessionService
|
||||
router?: RouterService
|
||||
theme?: ThemeService
|
||||
preferences?: PreferencesService
|
||||
endpoints?: Partial<typeof endpointImplementations>
|
||||
}
|
||||
|
||||
@@ -79,6 +90,7 @@ export function createAppServices(overrides: AppServiceOverrides = {}): AppServi
|
||||
session: overrides.session ?? defaultAppServices.session,
|
||||
router: overrides.router ?? defaultAppServices.router,
|
||||
theme: overrides.theme ?? defaultAppServices.theme,
|
||||
preferences: overrides.preferences ?? defaultAppServices.preferences,
|
||||
endpoints: { ...defaultAppServices.endpoints, ...overrides.endpoints },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Notification } from './api/types'
|
||||
import type { CustomEmoji, Notification } from './api/types'
|
||||
import { toPlainText } from './util/html'
|
||||
import { displayNameOf, profilePath } from './util/profile'
|
||||
|
||||
@@ -53,6 +53,30 @@ export interface NotificationPresentation {
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface NotificationEmoji {
|
||||
text: string
|
||||
emojis: CustomEmoji[]
|
||||
}
|
||||
|
||||
/** Turn Pleroma's separate reaction name/URL fields into EmojiText input. */
|
||||
export function emojiForNotification(notification: Notification): NotificationEmoji | null {
|
||||
const text = notification.emoji?.trim()
|
||||
if (!text) return null
|
||||
const shortcode = text.match(/^:([^:]+):$/)?.[1]
|
||||
if (!shortcode || !notification.emoji_url) return { text, emojis: [] }
|
||||
return {
|
||||
text,
|
||||
emojis: [
|
||||
{
|
||||
shortcode,
|
||||
url: notification.emoji_url,
|
||||
static_url: notification.emoji_url,
|
||||
visible_in_picker: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE: Record<string, string> = {
|
||||
mention: 'mentioned you in an entry',
|
||||
status: 'posted a new entry',
|
||||
@@ -62,6 +86,8 @@ const MESSAGE: Record<string, string> = {
|
||||
favourite: 'gave your entry kudos',
|
||||
poll: 'has a poll that just ended',
|
||||
update: 'edited an entry',
|
||||
quote: 'quoted your entry',
|
||||
quoted_update: 'edited an entry you quoted',
|
||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||
'admin.sign_up': 'joined the server',
|
||||
'admin.report': 'was included in a report',
|
||||
|
||||
@@ -25,12 +25,14 @@ const ROUTES: RoutePattern[] = [
|
||||
{ name: 'home', pattern: '/' },
|
||||
{ name: 'login', pattern: '/login' },
|
||||
{ name: 'settings', pattern: '/settings' },
|
||||
{ name: 'about', pattern: '/about' },
|
||||
{ name: 'browse', pattern: '/browse' },
|
||||
{ name: 'search', pattern: '/search' },
|
||||
{ name: 'mail', pattern: '/mail' },
|
||||
{ name: 'mail.folder', pattern: '/mail/:folder' },
|
||||
{ name: 'timeline', pattern: '/timeline/:kind' },
|
||||
{ name: 'tag', pattern: '/tag/:tag' },
|
||||
{ name: 'blog.quotes', pattern: '/blog/:id/quotes' },
|
||||
{ name: 'blog.entry', pattern: '/blog/:id' },
|
||||
{ name: 'compose', pattern: '/compose' },
|
||||
// Account routes come last: `:acct` is greedy enough to shadow the others.
|
||||
|
||||
@@ -10,4 +10,18 @@ describe('parseHash', () => {
|
||||
expect(() => parseHash('#/@broken%ZZ')).not.toThrow()
|
||||
expect(parseHash('#/@broken%ZZ').name).toBe('notfound')
|
||||
})
|
||||
|
||||
it('matches the quote-list route before a blog entry', () => {
|
||||
expect(parseHash('#/blog/status%2Fone/quotes')).toMatchObject({
|
||||
name: 'blog.quotes',
|
||||
params: { id: 'status/one' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the public About page', () => {
|
||||
expect(parseHash('#/about')).toMatchObject({
|
||||
name: 'about',
|
||||
params: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,27 @@
|
||||
/** Private, browser-local feature preferences. */
|
||||
|
||||
const HELENEPOSTING_KEY = 'plspace:heleneposting'
|
||||
const GREENTEXTING_KEY = 'plspace:greentexting'
|
||||
|
||||
class Preferences {
|
||||
heleneposting = $state(false)
|
||||
greentexting = $state(false)
|
||||
|
||||
constructor() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
this.heleneposting = localStorage.getItem(HELENEPOSTING_KEY) === 'true'
|
||||
this.greentexting = localStorage.getItem(GREENTEXTING_KEY) === 'true'
|
||||
}
|
||||
|
||||
setHeleneposting(enabled: boolean): void {
|
||||
this.heleneposting = enabled
|
||||
localStorage.setItem(HELENEPOSTING_KEY, String(enabled))
|
||||
}
|
||||
|
||||
setGreentexting(enabled: boolean): void {
|
||||
this.greentexting = enabled
|
||||
localStorage.setItem(GREENTEXTING_KEY, String(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
export const preferences = new Preferences()
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { account, status } from '$test/fixtures'
|
||||
import { accountForStatus, isHelenepost } from './heleneposting'
|
||||
|
||||
describe('Heleneposting detection', () => {
|
||||
it.each([
|
||||
'<p>A note<br>-Helene</p>',
|
||||
'<p>A note</p><p>-- Helene</p>',
|
||||
'<p>A note —<strong>Helene</strong></p>',
|
||||
'<blockquote>A note</blockquote><p>— Helene </p>',
|
||||
'<div><span class="h-card">@sun</span> that is woke —helene</div>',
|
||||
])('recognizes a signed HTML note: %s', (content) => {
|
||||
expect(isHelenepost(content)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'<p>Helene</p>',
|
||||
'<p>---Helene</p>',
|
||||
'<p>—Helene!</p>',
|
||||
'<p>—Helene wrote this</p>',
|
||||
'<p>—helen</p>',
|
||||
])('rejects content that is not an exact final signature: %s', (content) => {
|
||||
expect(isHelenepost(content)).toBe(false)
|
||||
})
|
||||
|
||||
it('substitutes presentation fields without changing account identity', () => {
|
||||
const original = account({
|
||||
id: 'actual-id',
|
||||
acct: 'actual@remote.test',
|
||||
display_name: 'Actual Author',
|
||||
avatar: 'https://remote.test/avatar.png',
|
||||
})
|
||||
const presented = accountForStatus(
|
||||
status({ account: original, content: '<p>Hello —Helene</p>' }),
|
||||
true,
|
||||
)
|
||||
|
||||
expect(presented).toMatchObject({
|
||||
id: 'actual-id',
|
||||
acct: 'actual@remote.test',
|
||||
display_name: 'Helene',
|
||||
})
|
||||
expect(presented.avatar).toContain('helene')
|
||||
expect(presented.avatar_static).toBe(presented.avatar)
|
||||
expect(original.display_name).toBe('Actual Author')
|
||||
})
|
||||
|
||||
it('does nothing while the feature is disabled', () => {
|
||||
const original = account()
|
||||
expect(
|
||||
accountForStatus(status({ account: original, content: '<p>—Helene</p>' }), false),
|
||||
).toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import heleneAvatarUrl from '../../assets/helene.png'
|
||||
import type { Account, Notification, Status } from '../api/types'
|
||||
import { toPlainText } from './html'
|
||||
|
||||
/**
|
||||
* A Helene signature is the final visible text in a note. Work from the
|
||||
* sanitized plain-text projection rather than the API's HTML so closing tags,
|
||||
* nested formatting and encoded em dashes cannot obscure the suffix.
|
||||
*/
|
||||
export function isHelenepost(content: string | null | undefined): boolean {
|
||||
const text = toPlainText(content)
|
||||
return /(?:(?<!-)--?|—)\s*Helene$/i.test(text)
|
||||
}
|
||||
|
||||
/** Preserve the real account identity and links while changing its presentation. */
|
||||
export function accountForStatus(status: Status, enabled: boolean): Account {
|
||||
if (!enabled || !isHelenepost(status.content)) return status.account
|
||||
return {
|
||||
...status.account,
|
||||
display_name: 'Helene',
|
||||
avatar: heleneAvatarUrl,
|
||||
avatar_static: heleneAvatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification's status is not always authored by its actor (a favourite
|
||||
* notification includes the recipient's status). Only transform the actor
|
||||
* when the attached note confirms that they authored it.
|
||||
*/
|
||||
export function accountForNotification(notification: Notification, enabled: boolean): Account {
|
||||
if (
|
||||
!notification.status ||
|
||||
notification.status.account.id !== notification.account.id
|
||||
) {
|
||||
return notification.account
|
||||
}
|
||||
return accountForStatus(notification.status, enabled)
|
||||
}
|
||||
+19
-3
@@ -119,6 +119,15 @@ export function escapeHtml(value: string): string {
|
||||
* Runs on the *sanitized* string and only injects `<img>` with a URL taken from
|
||||
* the emoji list, so it cannot reintroduce markup from the original content.
|
||||
*/
|
||||
export function safeCustomEmojiUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value, window.location.href)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
|
||||
if (!emojis?.length) return html
|
||||
const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji]))
|
||||
@@ -128,7 +137,9 @@ function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
|
||||
const replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => {
|
||||
const emoji = table.get(shortcode)
|
||||
if (!emoji) return whole
|
||||
return `<img class="custom-emoji" src="${escapeHtml(emoji.url)}" alt=":${escapeHtml(
|
||||
const url = safeCustomEmojiUrl(emoji.url)
|
||||
if (!url) return whole
|
||||
return `<img class="custom-emoji" src="${escapeHtml(url)}" alt=":${escapeHtml(
|
||||
shortcode,
|
||||
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
|
||||
})
|
||||
@@ -213,7 +224,12 @@ export function toPlainText(source: string | null | undefined): string {
|
||||
return (container.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Emoji-substituted display name, safe for `{@html}`. */
|
||||
/** Emoji-substituted plain text, escaped and safe for `{@html}`. */
|
||||
export function renderEmojiText(text: string, emojis: CustomEmoji[] | undefined): string {
|
||||
return applyEmojis(escapeHtml(text), emojis)
|
||||
}
|
||||
|
||||
/** Backward-compatible semantic name for existing display-name callers. */
|
||||
export function renderDisplayName(name: string, emojis: CustomEmoji[] | undefined): string {
|
||||
return applyEmojis(escapeHtml(name), emojis)
|
||||
return renderEmojiText(name, emojis)
|
||||
}
|
||||
|
||||
+161
-1
@@ -15,6 +15,8 @@ export interface MfmRenderOptions extends RenderOptions {
|
||||
pause?: boolean
|
||||
/** Scale effects against the surrounding custom-emoji size. */
|
||||
scale?: boolean
|
||||
/** Colour visual lines whose prose starts with `>`, following Pleroma-FE. */
|
||||
greentext?: boolean
|
||||
}
|
||||
|
||||
const LOOPING_OPERATORS = new Set([
|
||||
@@ -27,6 +29,163 @@ const LOOPING_OPERATORS = new Set([
|
||||
'rainbow',
|
||||
])
|
||||
|
||||
const EMPTY_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
])
|
||||
|
||||
const BLOCK_ELEMENTS = new Set([
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'blockquote',
|
||||
'details',
|
||||
'dialog',
|
||||
'dd',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'fieldset',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'footer',
|
||||
'form',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'li',
|
||||
'main',
|
||||
'nav',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'section',
|
||||
'table',
|
||||
'ul',
|
||||
])
|
||||
|
||||
const VISUAL_LINE_ELEMENTS = new Set([...BLOCK_ELEMENTS, 'br'])
|
||||
const NON_EMPTY_LINE_ELEMENTS = new Set(
|
||||
[...VISUAL_LINE_ELEMENTS].filter((element) => !EMPTY_ELEMENTS.has(element)),
|
||||
)
|
||||
const RECOGNIZED_LINE_ELEMENTS = new Set([
|
||||
...NON_EMPTY_LINE_ELEMENTS,
|
||||
...EMPTY_ELEMENTS,
|
||||
])
|
||||
|
||||
interface HtmlLine {
|
||||
level: string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
function tagName(tag: string): string | null {
|
||||
const match = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/is.exec(tag)
|
||||
return (match?.[1] ?? match?.[2] ?? null)?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pleroma-FE-compatible visual-line tokenizer. Inline markup remains in its
|
||||
* line, while block elements, `<br>`, and literal newlines form boundaries.
|
||||
*/
|
||||
function htmlLines(html: string): Array<string | HtmlLine> {
|
||||
const output: Array<string | HtmlLine> = []
|
||||
const level: string[] = []
|
||||
let text = ''
|
||||
let tag: string | null = null
|
||||
|
||||
const flush = (): void => {
|
||||
output.push(text.trim() ? { level: [...level], text } : text)
|
||||
text = ''
|
||||
}
|
||||
|
||||
for (const character of html) {
|
||||
if (character === '<' && tag === null) {
|
||||
tag = character
|
||||
} else if (character !== '>' && tag !== null) {
|
||||
tag += character
|
||||
} else if (character === '>' && tag !== null) {
|
||||
tag += character
|
||||
const complete = tag
|
||||
tag = null
|
||||
const name = tagName(complete)
|
||||
if (!name || !RECOGNIZED_LINE_ELEMENTS.has(name)) {
|
||||
text += complete
|
||||
} else if (name === 'br') {
|
||||
flush()
|
||||
output.push(complete)
|
||||
} else if (NON_EMPTY_LINE_ELEMENTS.has(name)) {
|
||||
if (complete[1] === '/') {
|
||||
if (level[0] === name) {
|
||||
flush()
|
||||
output.push(complete)
|
||||
level.shift()
|
||||
} else {
|
||||
text += complete
|
||||
}
|
||||
} else if (complete[complete.length - 2] === '/') {
|
||||
flush()
|
||||
output.push(complete)
|
||||
} else {
|
||||
flush()
|
||||
output.push(complete)
|
||||
level.unshift(name)
|
||||
}
|
||||
} else {
|
||||
text += complete
|
||||
}
|
||||
} else if (character === '\n') {
|
||||
flush()
|
||||
output.push(character)
|
||||
} else {
|
||||
text += character
|
||||
}
|
||||
}
|
||||
|
||||
if (tag) text += tag
|
||||
flush()
|
||||
return output
|
||||
}
|
||||
|
||||
/** Add trusted presentation spans after sanitization, never before it. */
|
||||
export function enhanceGreentextHtml(html: string, enabled: boolean): string {
|
||||
if (!enabled || !html.includes('>')) return html
|
||||
|
||||
return htmlLines(html)
|
||||
.map((line) => {
|
||||
if (typeof line === 'string') return line
|
||||
if (!line.level.every((element) => element === 'p' || element === 'div')) {
|
||||
return line.text
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = line.text
|
||||
const prose = (container.textContent ?? '').replace(/@\w+/gi, '').trim()
|
||||
return prose.startsWith('>')
|
||||
? `<span class="greentext">${line.text}</span>`
|
||||
: line.text
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function numberAttribute(element: Element, name: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
|
||||
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
|
||||
@@ -173,5 +332,6 @@ export function renderMfmHtml(
|
||||
source: string | null | undefined,
|
||||
options: MfmRenderOptions = {},
|
||||
): string {
|
||||
return enhanceMfmHtml(renderHtml(source, options), options)
|
||||
const html = enhanceGreentextHtml(renderHtml(source, options), options.greentext === true)
|
||||
return enhanceMfmHtml(html, options)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { Account } from '../api/types'
|
||||
import { renderHtml, toPlainText } from './html'
|
||||
import { topEightFromHtml } from './top-eight'
|
||||
|
||||
/** The interest rows a MySpace profile shipped with, in their original order. */
|
||||
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
|
||||
@@ -70,6 +71,8 @@ export interface ProfileView {
|
||||
about: string
|
||||
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
|
||||
wantsToMeet: string
|
||||
/** Fully-qualified handles declared in the portable bio section. */
|
||||
topEightHandles: string[]
|
||||
interests: InterestEntry[]
|
||||
details: ProfileField[]
|
||||
}
|
||||
@@ -124,7 +127,9 @@ export function fallbackMood(seed: string): string {
|
||||
}
|
||||
|
||||
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 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))
|
||||
|
||||
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 parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
|
||||
@@ -169,6 +174,7 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
age: Number.isFinite(parsedAge) ? parsedAge : null,
|
||||
about,
|
||||
wantsToMeet: meet,
|
||||
topEightHandles: topEight.handles,
|
||||
interests,
|
||||
details,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { status } from '$test/fixtures'
|
||||
import { quoteReferenceOf, quotesCountOf } from './status'
|
||||
|
||||
describe('quote status normalization', () => {
|
||||
it('reads Pleroma embedded quotes and counts', () => {
|
||||
const quoted = status({ id: 'quoted-entry' })
|
||||
const outer = status({
|
||||
pleroma: {
|
||||
quote: quoted,
|
||||
quote_id: quoted.id,
|
||||
quote_visible: true,
|
||||
quotes_count: 4,
|
||||
},
|
||||
})
|
||||
|
||||
expect(quoteReferenceOf(outer)).toMatchObject({
|
||||
state: 'accepted',
|
||||
status: quoted,
|
||||
id: 'quoted-entry',
|
||||
})
|
||||
expect(quotesCountOf(outer)).toBe(4)
|
||||
})
|
||||
|
||||
it('reads Mastodon quote envelopes but hides blocked quote content', () => {
|
||||
const quoted = status({ id: 'quoted-entry' })
|
||||
|
||||
expect(
|
||||
quoteReferenceOf(
|
||||
status({
|
||||
quote: { state: 'accepted', quoted_status: quoted },
|
||||
quotes_count: 2,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ state: 'accepted', status: quoted })
|
||||
|
||||
expect(
|
||||
quoteReferenceOf(
|
||||
status({
|
||||
quote: { state: 'blocked_account', quoted_status: quoted },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ state: 'blocked_account', status: null })
|
||||
})
|
||||
|
||||
it('retains the target ID from a Mastodon shallow quote', () => {
|
||||
expect(
|
||||
quoteReferenceOf(
|
||||
status({
|
||||
quote: {
|
||||
state: 'accepted',
|
||||
quoted_status_id: 'shallow-target',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
state: 'accepted',
|
||||
status: null,
|
||||
id: 'shallow-target',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { QuoteState, Status, StatusQuote } from '../api/types'
|
||||
|
||||
export interface QuoteReference {
|
||||
state: QuoteState
|
||||
status: Status | null
|
||||
id: string | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
function isStatus(value: StatusQuote | Status): value is Status {
|
||||
return 'id' in value && 'account' in value
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Mastodon's quote envelope and Pleroma/Akkoma's extension fields.
|
||||
* Content is exposed only for accepted quotes; blocked and muted states retain
|
||||
* a Status in Mastodon's API but clients are expected not to display it.
|
||||
*/
|
||||
export function quoteReferenceOf(status: Status): QuoteReference | null {
|
||||
const raw = status.quote
|
||||
if (raw) {
|
||||
if (isStatus(raw)) {
|
||||
return { state: 'accepted', status: raw, id: raw.id, url: raw.url ?? raw.uri }
|
||||
}
|
||||
const visible = raw.state === 'accepted' ? (raw.quoted_status ?? null) : null
|
||||
return {
|
||||
state: raw.state || 'unauthorized',
|
||||
status: visible,
|
||||
id: raw.quoted_status?.id ?? raw.quoted_status_id ?? null,
|
||||
url: raw.quoted_status?.url ?? raw.quoted_status?.uri ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const pleroma = status.pleroma
|
||||
if (pleroma?.quote) {
|
||||
return {
|
||||
state: pleroma.quote_visible === false ? 'unauthorized' : 'accepted',
|
||||
status: pleroma.quote_visible === false ? null : pleroma.quote,
|
||||
id: pleroma.quote.id,
|
||||
url: pleroma.quote.url ?? pleroma.quote.uri,
|
||||
}
|
||||
}
|
||||
|
||||
const id = status.quote_id ?? pleroma?.quote_id ?? null
|
||||
const url = status.quote_url ?? pleroma?.quote_url ?? null
|
||||
if (!id && !url) return null
|
||||
return {
|
||||
state: pleroma?.quote_visible === false ? 'unauthorized' : 'pending',
|
||||
status: null,
|
||||
id,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
export function quotesCountOf(status: Status): number {
|
||||
return Math.max(0, status.quotes_count ?? 0, status.pleroma?.quotes_count ?? 0)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Module from '$components/common/Module.svelte'
|
||||
|
||||
const RADICLE_REPOSITORY = 'rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT'
|
||||
|
||||
$effect(() => {
|
||||
document.title = 'About plspace | plspace'
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page about-page">
|
||||
<h1 class="page-title">About plspace</h1>
|
||||
<p class="page-subtitle">It’s always Pleroma™.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="About plspace" variant="band">
|
||||
<p>
|
||||
plspace is free software licensed under the
|
||||
<strong>GNU Affero General Public License (AGPL)</strong>.
|
||||
</p>
|
||||
<p>
|
||||
The source code is in the canonical Radicle repository:
|
||||
<a href={RADICLE_REPOSITORY}><code>{RADICLE_REPOSITORY}</code></a>.
|
||||
</p>
|
||||
<p>
|
||||
Made by <a href="#/@zero@posting.solutions">@zero@posting.solutions</a>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Privacy policy:</strong> zero logging and zero tracking.
|
||||
</p>
|
||||
<p><strong>proudly 100% vibe-coded slop.</strong></p>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import About from './About.svelte'
|
||||
|
||||
describe('About', () => {
|
||||
it('identifies the license, canonical source, author, and provenance', () => {
|
||||
const view = render(About)
|
||||
|
||||
expect(view.getByRole('heading', { name: 'About plspace', level: 1 })).toBeInTheDocument()
|
||||
expect(view.getByText(/GNU Affero General Public License/)).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByRole('link', {
|
||||
name: 'rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT',
|
||||
}),
|
||||
).toHaveAttribute('href', 'rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT')
|
||||
expect(view.getByRole('link', { name: '@zero@posting.solutions' })).toHaveAttribute(
|
||||
'href',
|
||||
'#/@zero@posting.solutions',
|
||||
)
|
||||
expect(view.getByText(/zero logging and zero tracking/i)).toBeInTheDocument()
|
||||
expect(view.getByText('proudly 100% vibe-coded slop.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
/** A full-page composer, reachable from the nav and from "Send Message". */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
@@ -7,16 +9,53 @@
|
||||
interface Props {
|
||||
/** Handle to address the entry to, from `?to=`. */
|
||||
to?: string
|
||||
/** Local API ID of the entry being quoted, from `?quote=`. */
|
||||
quoteId?: string
|
||||
}
|
||||
|
||||
let { to }: Props = $props()
|
||||
let { to, quoteId }: Props = $props()
|
||||
|
||||
const { router, session } = useAppServices()
|
||||
const { endpoints, router, session } = useAppServices()
|
||||
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
|
||||
let quote = $state<Status | null>(null)
|
||||
let quoteLoading = $state(false)
|
||||
let quoteError = $state<string | null>(null)
|
||||
let loadGeneration = 0
|
||||
|
||||
$effect(() => {
|
||||
const id = quoteId
|
||||
const generation = ++loadGeneration
|
||||
quote = null
|
||||
quoteError = null
|
||||
if (!id) {
|
||||
quoteLoading = false
|
||||
return
|
||||
}
|
||||
quoteLoading = true
|
||||
untrack(() => void loadQuote(id, generation))
|
||||
return () => {
|
||||
if (generation === loadGeneration) loadGeneration += 1
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQuote(id: string, generation: number): Promise<void> {
|
||||
try {
|
||||
const found = await endpoints.fetchStatus(session.api, id)
|
||||
if (generation === loadGeneration && quoteId === id) quote = found.reblog ?? found
|
||||
} catch (cause) {
|
||||
if (generation === loadGeneration) {
|
||||
quoteError = cause instanceof Error ? cause.message : 'Could not load the quoted entry.'
|
||||
}
|
||||
} finally {
|
||||
if (generation === loadGeneration) quoteLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page compose-page">
|
||||
<h1 class="page-title">{to ? 'Send a Message' : 'Post a Blog Entry'}</h1>
|
||||
<h1 class="page-title">
|
||||
{quoteId ? 'Quote a Blog Entry' : to ? 'Send a Message' : 'Post a Blog Entry'}
|
||||
</h1>
|
||||
{#if to}
|
||||
<p class="page-subtitle">
|
||||
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
|
||||
@@ -25,16 +64,26 @@
|
||||
{/if}
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title={to ? 'New message' : 'New entry'}>
|
||||
{#key to}
|
||||
<Module title={quoteId ? 'New quote' : to ? 'New message' : 'New entry'}>
|
||||
{#if quoteLoading}
|
||||
<p class="loading-note">Loading quoted entry…</p>
|
||||
{:else if quoteError}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">The entry can’t be quoted.</strong>
|
||||
{quoteError}
|
||||
</p>
|
||||
{:else if !quoteId || quote}
|
||||
{#key `${to ?? ''}:${quoteId ?? ''}`}
|
||||
<Composer
|
||||
{quote}
|
||||
initialText={prefill}
|
||||
initialVisibility={to ? 'direct' : 'public'}
|
||||
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
||||
submitLabel={to ? 'Send' : 'Post Entry'}
|
||||
initialVisibility={quote?.visibility === 'private' ? 'private' : to ? 'direct' : 'public'}
|
||||
placeholder={quote ? 'Add a comment, or post the quote by itself…' : to ? 'Say something…' : 'What are you up to?'}
|
||||
submitLabel={quote ? 'Post Quote' : to ? 'Send' : 'Post Entry'}
|
||||
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
{#if !session.signedIn}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { account, session, testServices } from '$test/fixtures'
|
||||
import { account, session, status, testServices } from '$test/fixtures'
|
||||
import Compose from './Compose.svelte'
|
||||
|
||||
describe('Compose route', () => {
|
||||
@@ -22,4 +22,22 @@ describe('Compose route', () => {
|
||||
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ')
|
||||
expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct')
|
||||
})
|
||||
|
||||
it('loads and previews a quote target without a backend', async () => {
|
||||
const fetchStatus = vi.fn().mockResolvedValue(
|
||||
status({ id: 'quoted-entry', content: '<p>Loaded quote target</p>' }),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { fetchStatus },
|
||||
})
|
||||
const view = render(Compose, {
|
||||
props: { quoteId: 'quoted-entry' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await waitFor(() => expect(fetchStatus).toHaveBeenCalledOnce())
|
||||
expect(await view.findByText('Loaded quote target')).toBeInTheDocument()
|
||||
expect(view.getByRole('button', { name: 'Post Quote' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
+52
-10
@@ -15,17 +15,19 @@
|
||||
instanceThumbnail,
|
||||
} from '$lib/api/endpoints'
|
||||
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { escapeHtml, toPlainText } from '$lib/util/html'
|
||||
import { accountForStatus } from '$lib/util/heleneposting'
|
||||
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||
import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import MfmContent from '$components/common/MfmContent.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
const timelineRefresh = useTimelineRefresh()
|
||||
|
||||
let friendStatus = $state<Status[]>([])
|
||||
@@ -198,6 +200,12 @@
|
||||
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 {
|
||||
return cause instanceof Error ? cause.message : 'Could not load that.'
|
||||
}
|
||||
@@ -217,7 +225,11 @@
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="page-title">
|
||||
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if}
|
||||
{#if me}
|
||||
Hello, <EmojiText text={displayNameOf(me).split(/\s+/)[0]} emojis={me.emojis} />!
|
||||
{:else}
|
||||
{domain}
|
||||
{/if}
|
||||
</h1>
|
||||
{#if me}
|
||||
<p class="page-subtitle">
|
||||
@@ -243,7 +255,9 @@
|
||||
<Avatar account={me} size="friend" />
|
||||
</p>
|
||||
<p class="center">
|
||||
<a href={profilePath(me)}>{displayNameOf(me)}</a>
|
||||
<a href={profilePath(me)}>
|
||||
<EmojiText text={displayNameOf(me)} emojis={me.emojis} />
|
||||
</a>
|
||||
</p>
|
||||
<p class="center muted">
|
||||
Profile views: {formatCount(me.statuses_count)} entries
|
||||
@@ -323,19 +337,37 @@
|
||||
<ul class="status-line-list">
|
||||
{#each friendStatus as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
{@const author = accountForStatus(entry, preferences.heleneposting)}
|
||||
{@const imagePreview = imagePreviewFor(entry)}
|
||||
<li class="status-line" data-account={entry.account.acct}>
|
||||
<Avatar account={entry.account} />
|
||||
<Avatar account={author} />
|
||||
<div class="status-line-body">
|
||||
<a class="status-line-author" href={profilePath(entry.account)}>
|
||||
{displayNameOf(entry.account)}
|
||||
<EmojiText text={displayNameOf(author)} emojis={entry.account.emojis} />
|
||||
</a>
|
||||
<MfmContent
|
||||
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
|
||||
html={entry.spoiler_text ? `<p>${escapeHtml(entry.spoiler_text)}</p>` : entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
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}`}>
|
||||
{relativeTime(entry.created_at)}
|
||||
</a>
|
||||
@@ -374,14 +406,20 @@
|
||||
<tbody>
|
||||
{#each bulletins as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
{@const author = accountForStatus(entry, preferences.heleneposting)}
|
||||
<tr>
|
||||
<td class="bulletin-from">
|
||||
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</a>
|
||||
<a href={profilePath(entry.account)}>
|
||||
<EmojiText text={displayNameOf(author)} emojis={entry.account.emojis} />
|
||||
</a>
|
||||
</td>
|
||||
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
|
||||
<td class="bulletin-subject">
|
||||
<a href={`#/blog/${entry.id}`}>
|
||||
{toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
|
||||
<EmojiText
|
||||
text={toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
|
||||
emojis={entry.emojis}
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -407,7 +445,11 @@
|
||||
{#each following as friend (friend.id)}
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<EmojiText
|
||||
class="friend-card-name"
|
||||
text={displayNameOf(friend)}
|
||||
emojis={friend.emojis}
|
||||
/>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
|
||||
+39
-1
@@ -2,7 +2,7 @@ import { render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
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'
|
||||
|
||||
describe('Home timeline refresh', () => {
|
||||
@@ -38,3 +38,41 @@ describe('Home timeline refresh', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* anonymous timeline reads; suggesting one of those hands a first-time
|
||||
* visitor an empty page. Re-check before adding to this list.
|
||||
*/
|
||||
const SUGGESTIONS = ['pleroma.soykaf.com', 'lain.com', 'spinster.xyz']
|
||||
const SUGGESTIONS = ['pleroma.soykaf.com', 'lain.com', 'fosstodon.org']
|
||||
|
||||
async function signIn(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
+22
-5
@@ -13,10 +13,13 @@
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { accountForNotification } from '$lib/util/heleneposting'
|
||||
import { emojiForNotification } from '$lib/notifications'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
folder?: string
|
||||
@@ -24,7 +27,7 @@
|
||||
|
||||
let { folder = 'inbox' }: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
interface Folder {
|
||||
key: string
|
||||
label: string
|
||||
@@ -59,6 +62,8 @@
|
||||
poll: 'closed a poll you voted in',
|
||||
status: 'posted a new entry',
|
||||
update: 'edited an entry you interacted with',
|
||||
quote: 'quoted your entry',
|
||||
quoted_update: 'edited an entry you quoted',
|
||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||
}
|
||||
|
||||
@@ -160,7 +165,9 @@
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<a href={profilePath(account)}>{displayNameOf(account)}</a>
|
||||
<a href={profilePath(account)}>
|
||||
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
|
||||
</a>
|
||||
</strong>
|
||||
wants to be your friend!
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
@@ -205,23 +212,33 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each notifications.items as item (item.id)}
|
||||
{@const reaction = emojiForNotification(item)}
|
||||
{@const actor = accountForNotification(item, preferences.heleneposting)}
|
||||
<tr class="mail-row" data-kind={item.type} data-account={item.account.acct}>
|
||||
<td class="mail-table-date">{stampDate(item.created_at)}</td>
|
||||
<td class="mail-table-from">
|
||||
<a href={profilePath(item.account)}>
|
||||
<Avatar account={item.account} plain />
|
||||
<Avatar account={actor} plain />
|
||||
</a>
|
||||
</td>
|
||||
<td class="mail-table-subject">
|
||||
<strong>
|
||||
<a href={profilePath(item.account)}>{displayNameOf(item.account)}</a>
|
||||
<a href={profilePath(item.account)}>
|
||||
<EmojiText text={displayNameOf(actor)} emojis={item.account.emojis} />
|
||||
</a>
|
||||
</strong>
|
||||
{VERB[item.type] ?? item.type}
|
||||
{#if reaction}
|
||||
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
|
||||
{/if}
|
||||
{#if item.status}
|
||||
<p class="mail-table-excerpt">
|
||||
<a href={`#/blog/${item.status.id}`}>
|
||||
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
<EmojiText
|
||||
text={toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
'(no text)'}
|
||||
emojis={item.status.emojis}
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
+81
-11
@@ -24,12 +24,19 @@
|
||||
formatCount,
|
||||
} from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import {
|
||||
readTopEightCache,
|
||||
topEightHandleMatchesAccount,
|
||||
writeTopEightCache,
|
||||
} from '$lib/util/top-eight'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
|
||||
import ContactBox from '$components/profile/ContactBox.svelte'
|
||||
import InterestsTable from '$components/profile/InterestsTable.svelte'
|
||||
import DetailsTable from '$components/profile/DetailsTable.svelte'
|
||||
import FriendSpace from '$components/profile/FriendSpace.svelte'
|
||||
import TopEightSpace from '$components/profile/TopEightSpace.svelte'
|
||||
import PicStream from '$components/profile/PicStream.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
@@ -51,6 +58,9 @@
|
||||
|
||||
let friends = $state<Account[]>([])
|
||||
let friendsLoading = $state(false)
|
||||
let topEightAccounts = $state<Account[]>([])
|
||||
let topEightMissing = $state<string[]>([])
|
||||
let topEightLoading = $state(false)
|
||||
let loadGeneration = 0
|
||||
|
||||
// Recreated whenever the account changes, so the feed never shows one
|
||||
@@ -104,6 +114,9 @@
|
||||
relationship = null
|
||||
friends = []
|
||||
friendsLoading = false
|
||||
topEightAccounts = []
|
||||
topEightMissing = []
|
||||
topEightLoading = false
|
||||
|
||||
try {
|
||||
const found = await endpoints.lookupAccount(session.api, handle)
|
||||
@@ -144,6 +157,7 @@
|
||||
)
|
||||
void entries.reload()
|
||||
|
||||
void loadTopEight(found, generation)
|
||||
void loadFriends(found, currentView, generation)
|
||||
void loadRelationship(found, generation)
|
||||
} catch (cause) {
|
||||
@@ -154,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(
|
||||
target: Account,
|
||||
currentView: Props['view'],
|
||||
@@ -204,7 +242,9 @@
|
||||
{error}
|
||||
</p>
|
||||
{:else if account && profile}
|
||||
<h1 class="page-title profile-name">{displayNameOf(account)}</h1>
|
||||
<h1 class="page-title profile-name">
|
||||
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
|
||||
</h1>
|
||||
|
||||
{#if account.moved}
|
||||
<p class="profile-moved">
|
||||
@@ -229,10 +269,18 @@
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
|
||||
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
|
||||
<InterestsTable
|
||||
title={`${firstName}'s Interests`}
|
||||
interests={profile.interests}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
<DetailsTable
|
||||
title={`${firstName}'s Details`}
|
||||
fields={profile.details}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
|
||||
<Module title={`${firstName}'s Stats`} flush>
|
||||
<Module title={`${firstName}'s Stats`} titleEmojis={account.emojis} flush>
|
||||
<table class="data-table stats-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -275,6 +323,7 @@
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
ownerEmojis={account.emojis}
|
||||
{friends}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}`}
|
||||
@@ -283,7 +332,7 @@
|
||||
{countHidden}
|
||||
/>
|
||||
{:else if view === 'blog'}
|
||||
<Module title={`${firstName}'s Blog`} variant="band">
|
||||
<Module title={`${firstName}'s Blog`} titleEmojis={account.emojis} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
@@ -309,12 +358,12 @@
|
||||
/>
|
||||
</Module>
|
||||
{:else if view === 'pics'}
|
||||
<Module title={`${firstName}'s Pics`} variant="band">
|
||||
<Module title={`${firstName}'s Pics`} titleEmojis={account.emojis} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
|
||||
<PicStream feed={entries} ownerName={firstName} />
|
||||
<PicStream feed={entries} ownerName={firstName} ownerEmojis={account.emojis} />
|
||||
</Module>
|
||||
{:else}
|
||||
<!--
|
||||
@@ -323,7 +372,11 @@
|
||||
posts of media push the Blurbs and Friend Space off the bottom,
|
||||
which is the wrong shape for a profile.
|
||||
-->
|
||||
<Module title={`${firstName}'s Latest Blog Entries`} variant="band">
|
||||
<Module
|
||||
title={`${firstName}'s Latest Blog Entries`}
|
||||
titleEmojis={account.emojis}
|
||||
variant="band"
|
||||
>
|
||||
{#snippet action()}
|
||||
<a href={`${base}/blog`}>[View Blog]</a>
|
||||
{/snippet}
|
||||
@@ -337,7 +390,11 @@
|
||||
{#each entries.items.slice(0, 6) as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
<li class="entry-teaser" data-status-id={entry.id}>
|
||||
<span class="entry-teaser-text">{teaserFor(entry)}</span>
|
||||
<EmojiText
|
||||
class="entry-teaser-text"
|
||||
text={teaserFor(entry)}
|
||||
emojis={entry.emojis}
|
||||
/>
|
||||
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -348,7 +405,7 @@
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`${firstName}'s Blurbs`} variant="band">
|
||||
<Module title={`${firstName}'s Blurbs`} titleEmojis={account.emojis} variant="band">
|
||||
<h3 class="section-heading">About me:</h3>
|
||||
{#if profile.about}
|
||||
<div class="rich-text blurb-body">
|
||||
@@ -356,7 +413,9 @@
|
||||
{@html profile.about}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-note">{firstName} hasn't written an About me yet.</p>
|
||||
<p class="empty-note">
|
||||
<EmojiText text={firstName} emojis={account.emojis} /> hasn't written an About me yet.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h3 class="section-heading">Who I'd like to meet:</h3>
|
||||
@@ -372,9 +431,20 @@
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
{#if profile.topEightHandles.length > 0}
|
||||
<TopEightSpace
|
||||
ownerName={firstName}
|
||||
ownerEmojis={account.emojis}
|
||||
accounts={topEightAccounts}
|
||||
missing={topEightMissing}
|
||||
loading={topEightLoading}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
ownerEmojis={account.emojis}
|
||||
friends={friends.slice(0, FRIEND_PREVIEW)}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}/friends`}
|
||||
|
||||
@@ -7,6 +7,51 @@ import { account, deferred, session, status, testServices, theme } from '$test/f
|
||||
import Profile from './Profile.svelte'
|
||||
|
||||
describe('Profile', () => {
|
||||
it('renders account custom emoji in the public profile heading', async () => {
|
||||
const profileAccount = account({
|
||||
display_name: ':smugkura: Kura :disconnecting: :kura_explode:',
|
||||
emojis: [
|
||||
{
|
||||
shortcode: 'smugkura',
|
||||
url: 'https://cdn.example/smugkura.png',
|
||||
static_url: 'https://cdn.example/smugkura.png',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
{
|
||||
shortcode: 'disconnecting',
|
||||
url: 'https://cdn.example/disconnecting.png',
|
||||
static_url: 'https://cdn.example/disconnecting.png',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
{
|
||||
shortcode: 'kura_explode',
|
||||
url: 'https://cdn.example/kura_explode.png',
|
||||
static_url: 'https://cdn.example/kura_explode.png',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const services = testServices({
|
||||
session: session(),
|
||||
theme: theme(),
|
||||
endpoints: {
|
||||
lookupAccount: vi.fn().mockResolvedValue(profileAccount),
|
||||
fetchAccountStatuses: vi.fn().mockResolvedValue({ items: [], links: {} }),
|
||||
},
|
||||
})
|
||||
const view = render(Profile, {
|
||||
props: { acct: 'alice' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
const headingEmoji = (await view.findAllByAltText(':smugkura:')).find((node) =>
|
||||
node.closest('h1.profile-name'),
|
||||
)
|
||||
expect(headingEmoji).toBeDefined()
|
||||
expect(view.getAllByAltText(':disconnecting:').length).toBeGreaterThan(0)
|
||||
expect(view.getAllByAltText(':kura_explode:').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not apply profile CSS after it has unmounted', async () => {
|
||||
const lookup = deferred<Account>()
|
||||
const applyProfileCss = vi.fn()
|
||||
@@ -68,6 +113,44 @@ describe('Profile', () => {
|
||||
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 () => {
|
||||
const ownPicture = status({
|
||||
id: 'picture-entry',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
/** Paginated posts that quote one source entry. */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import BlogList from '$components/blog/BlogList.svelte'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
let { id }: Props = $props()
|
||||
const { endpoints, session } = useAppServices()
|
||||
const timelineRefresh = useTimelineRefresh()
|
||||
|
||||
let source = $state<Status | null>(null)
|
||||
let sourceError = $state<string | null>(null)
|
||||
let sourceLoading = $state(true)
|
||||
let loadGeneration = 0
|
||||
let feed = $state<Feed<Status>>(
|
||||
new Feed<Status>(async () => ({ items: [], links: {} })),
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const currentId = id
|
||||
const generation = ++loadGeneration
|
||||
source = null
|
||||
sourceError = null
|
||||
sourceLoading = true
|
||||
feed = new Feed<Status>((cursor) => endpoints.fetchQuotes(session.api, currentId, cursor))
|
||||
untrack(() => {
|
||||
void feed.reload()
|
||||
void loadSource(currentId, generation)
|
||||
})
|
||||
return () => {
|
||||
if (generation === loadGeneration) loadGeneration += 1
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => timelineRefresh?.register(() => void feed.refresh()))
|
||||
|
||||
async function loadSource(currentId: string, generation: number): Promise<void> {
|
||||
try {
|
||||
const found = await endpoints.fetchStatus(session.api, currentId)
|
||||
if (generation === loadGeneration && id === currentId) {
|
||||
source = found
|
||||
document.title = 'Quotes | plspace'
|
||||
}
|
||||
} catch (cause) {
|
||||
if (generation === loadGeneration) {
|
||||
sourceError = cause instanceof Error ? cause.message : 'Could not load the original entry.'
|
||||
}
|
||||
} finally {
|
||||
if (generation === loadGeneration) sourceLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page quotes-page">
|
||||
<h1 class="page-title">Quotes of this Blog Entry</h1>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Original Entry" variant="band">
|
||||
{#if sourceLoading}
|
||||
<p class="loading-note">Loading original entry…</p>
|
||||
{:else if sourceError}
|
||||
<p class="error-note" role="alert">{sourceError}</p>
|
||||
{:else if source}
|
||||
<BlogEntry
|
||||
status={source}
|
||||
compact
|
||||
onupdate={(updated) => (source = updated)}
|
||||
/>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Entries quoting this" variant="band">
|
||||
<BlogList
|
||||
{feed}
|
||||
emptyText="Nobody has quoted this entry yet."
|
||||
label="View More Quotes"
|
||||
longFormDate
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { session, status, testServices } from '$test/fixtures'
|
||||
import Quotes from './Quotes.svelte'
|
||||
|
||||
describe('Quotes route', () => {
|
||||
it('shows the source and paginated quoting entries', async () => {
|
||||
const services = testServices({
|
||||
session: session(),
|
||||
endpoints: {
|
||||
fetchStatus: vi.fn().mockResolvedValue(
|
||||
status({ id: 'source', content: '<p>Original entry</p>' }),
|
||||
),
|
||||
fetchQuotes: vi.fn().mockResolvedValue({
|
||||
items: [status({ id: 'quote-1', content: '<p>A quoting entry</p>' })],
|
||||
links: {},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const view = render(Quotes, {
|
||||
props: { id: 'source' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(await view.findByText('Original entry')).toBeInTheDocument()
|
||||
expect(await view.findByText('A quoting entry')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,11 @@
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte'
|
||||
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
|
||||
|
||||
const { session, theme } = useAppServices()
|
||||
const { session, theme, preferences } = useAppServices()
|
||||
|
||||
let draft = $state(theme.viewerCss)
|
||||
let saved = $state(false)
|
||||
@@ -65,6 +66,7 @@
|
||||
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
||||
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
||||
['.youtube-attachment, .youtube-embed', 'YouTube embeds'],
|
||||
['.greentext', 'Opt-in lines beginning with a meme arrow'],
|
||||
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||
],
|
||||
@@ -97,7 +99,9 @@
|
||||
{#if session.signedIn && session.me}
|
||||
<p>
|
||||
Signed in as
|
||||
<a href={profilePath(session.me)}>{displayNameOf(session.me)}</a>
|
||||
<a href={profilePath(session.me)}>
|
||||
<EmojiText text={displayNameOf(session.me)} emojis={session.me.emojis} />
|
||||
</a>
|
||||
on <strong>{domain}</strong>.
|
||||
</p>
|
||||
<p class="field-row">
|
||||
@@ -123,6 +127,37 @@
|
||||
<PublicProfileEditor />
|
||||
</Module>
|
||||
|
||||
<Module title="Bonus features" variant="band">
|
||||
<label class="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.heleneposting}
|
||||
onchange={(event) => preferences.setHeleneposting(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Enable Heleneposting</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
When a note ends with <code>-Helene</code>, <code>--Helene</code>, or
|
||||
<code>—Helene</code>, show its author as Helene with the Heleneposting avatar.
|
||||
This is a private display preference stored only in this browser; it does not modify
|
||||
anyone's posts or profile.
|
||||
</p>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.greentexting}
|
||||
onchange={(event) => preferences.setGreentexting(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Enable Greentexting</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Show lines beginning with <code>></code> in 4chan-style green. Leading mentions
|
||||
are ignored when detecting the beginning of a line. This affects display only and is
|
||||
stored in this browser.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Pick a layout" variant="band">
|
||||
<ul class="preset-list">
|
||||
{#each PRESETS as preset (preset.id)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { testServices } from '$test/fixtures'
|
||||
import { preferences, testServices } from '$test/fixtures'
|
||||
import Settings from './Settings.svelte'
|
||||
|
||||
describe('Settings CSS language', () => {
|
||||
@@ -20,3 +20,47 @@ describe('Settings CSS language', () => {
|
||||
expect(view.getByText(/anyone who visits your profile with plspace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings bonus features', () => {
|
||||
it('explains and controls the browser-local Heleneposting preference', async () => {
|
||||
const setHeleneposting = vi.fn()
|
||||
const view = render(Settings, {
|
||||
context: new Map([
|
||||
[
|
||||
APP_SERVICES,
|
||||
testServices({
|
||||
preferences: preferences({ setHeleneposting }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const checkbox = view.getByRole('checkbox', { name: 'Enable Heleneposting' })
|
||||
expect(checkbox).not.toBeChecked()
|
||||
expect(view.getByText(/stored only in this browser/i)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(checkbox)
|
||||
expect(setHeleneposting).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('controls the browser-local Greentexting preference', async () => {
|
||||
const setGreentexting = vi.fn()
|
||||
const view = render(Settings, {
|
||||
context: new Map([
|
||||
[
|
||||
APP_SERVICES,
|
||||
testServices({
|
||||
preferences: preferences({ setGreentexting }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const checkbox = view.getByRole('checkbox', { name: 'Enable Greentexting' })
|
||||
expect(checkbox).not.toBeChecked()
|
||||
expect(view.getByText(/4chan-style green/i)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(checkbox)
|
||||
expect(setGreentexting).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import { stampDate, isoDate } from '$lib/util/time'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { accountForStatus } from '$lib/util/heleneposting'
|
||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
import MfmContent from '$components/common/MfmContent.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
@@ -25,7 +27,7 @@
|
||||
|
||||
let { id }: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const { endpoints, session, preferences } = useAppServices()
|
||||
const timelineRefresh = useTimelineRefresh()
|
||||
let status = $state<Status | null>(null)
|
||||
let ancestors = $state<Status[]>([])
|
||||
@@ -33,6 +35,9 @@
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let loadGeneration = 0
|
||||
const statusAuthor = $derived(
|
||||
status ? accountForStatus(status, preferences.heleneposting) : null,
|
||||
)
|
||||
|
||||
interface ThreadedReply {
|
||||
status: Status
|
||||
@@ -162,7 +167,12 @@
|
||||
</p>
|
||||
{:else if status}
|
||||
<h1 class="page-title">
|
||||
<a href={profilePath(status.account)}>{displayNameOf(status.account)}</a>'s Blog
|
||||
<a href={profilePath(status.account)}>
|
||||
<EmojiText
|
||||
text={displayNameOf(statusAuthor ?? status.account)}
|
||||
emojis={status.account.emojis}
|
||||
/>
|
||||
</a>'s Blog
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
|
||||
@@ -213,13 +223,17 @@
|
||||
{:else}
|
||||
<ul class="comment-list">
|
||||
{#each thread as reply (reply.status.id)}
|
||||
{@const author = accountForStatus(reply.status, preferences.heleneposting)}
|
||||
<li class="comment" data-depth={reply.depth} data-account={reply.status.account.acct}>
|
||||
<div class="comment-avatar">
|
||||
<Avatar account={reply.status.account} />
|
||||
<Avatar account={author} />
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<a class="comment-author" href={profilePath(reply.status.account)}>
|
||||
{displayNameOf(reply.status.account)}
|
||||
<EmojiText
|
||||
text={displayNameOf(author)}
|
||||
emojis={reply.status.account.emojis}
|
||||
/>
|
||||
</a>
|
||||
<a class="comment-date" href={`#/blog/${reply.status.id}`}>
|
||||
<time datetime={isoDate(reply.status.created_at)}>
|
||||
@@ -228,7 +242,9 @@
|
||||
</a>
|
||||
{#if reply.status.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
||||
<summary class="content-warning-summary">
|
||||
<EmojiText text={reply.status.spoiler_text} emojis={reply.status.emojis} />
|
||||
</summary>
|
||||
<MfmContent
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
|
||||
+2
-1
@@ -175,8 +175,9 @@ table {
|
||||
|
||||
/* Custom emoji injected into sanitized HTML by lib/util/html.ts. */
|
||||
.custom-emoji {
|
||||
width: var(--ms-emoji-size);
|
||||
height: var(--ms-emoji-size);
|
||||
width: auto;
|
||||
max-width: calc(var(--ms-emoji-size) * 2);
|
||||
vertical-align: text-bottom;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
+234
-6
@@ -32,19 +32,22 @@
|
||||
}
|
||||
|
||||
.blog-entry-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.blog-entry-avatar {
|
||||
flex: 0 0 auto;
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.blog-entry-byline {
|
||||
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 {
|
||||
@@ -73,6 +76,10 @@
|
||||
margin-left: calc(var(--ms-avatar-size) + 8px);
|
||||
}
|
||||
|
||||
.rich-text .greentext {
|
||||
color: var(--ms-greentext);
|
||||
}
|
||||
|
||||
.blog-entry[data-compact='true'] .blog-entry-body {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -144,6 +151,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attachment-figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachment-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -173,6 +184,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));
|
||||
@@ -361,6 +434,142 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Existing Pleroma/Akkoma reactions. The whole bar is absent when empty. */
|
||||
.emoji-reaction-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.emoji-reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 34px;
|
||||
min-height: 25px;
|
||||
margin: 0;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--ms-table-border);
|
||||
border-radius: 0;
|
||||
color: var(--ms-page-fg);
|
||||
background: var(--ms-page-bg);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji-reaction:hover:not(:disabled) {
|
||||
border-color: var(--ms-link);
|
||||
background: var(--ms-highlight-bg);
|
||||
}
|
||||
|
||||
.emoji-reaction[aria-pressed='true'] {
|
||||
border-color: var(--ms-link);
|
||||
color: var(--ms-link);
|
||||
background: var(--ms-table-label-bg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emoji-reaction:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.emoji-reaction-image {
|
||||
display: block;
|
||||
height: 20px;
|
||||
width: auto;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-reaction-glyph {
|
||||
font-size: 17px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.emoji-reaction-count {
|
||||
min-width: 1ch;
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.emoji-reaction[aria-pressed='true'] .emoji-reaction-count {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.emoji-reaction-error {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- quote cards */
|
||||
|
||||
.quote-card {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--ms-module-border);
|
||||
background: var(--ms-page-bg);
|
||||
}
|
||||
|
||||
.quote-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 7px;
|
||||
border-bottom: 1px solid var(--ms-table-border);
|
||||
background: var(--ms-table-stripe-bg);
|
||||
}
|
||||
|
||||
.quote-card-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.quote-card-byline {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quote-card-author {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quote-card-handle,
|
||||
.quote-card-date {
|
||||
margin-left: 5px;
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
.quote-card-body {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.quote-card .attachment-list,
|
||||
.quote-card .preview-card,
|
||||
.quote-card .poll {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.quote-card-placeholder,
|
||||
.quote-card-nested {
|
||||
margin: 0;
|
||||
padding: 7px;
|
||||
color: var(--ms-muted-fg);
|
||||
}
|
||||
|
||||
.quote-card-placeholder a {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
|
||||
/* Mastodon inserts this compatibility link when a structured quote is posted. */
|
||||
.quote-post-content .quote-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- comments */
|
||||
|
||||
.comment-list {
|
||||
@@ -498,3 +707,22 @@
|
||||
color: var(--ms-muted-fg);
|
||||
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;
|
||||
}
|
||||
|
||||
/* `.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 */
|
||||
|
||||
.site-nav {
|
||||
|
||||
+101
-1
@@ -131,6 +131,26 @@ textarea {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.composer-quote {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.composer-quote-label {
|
||||
margin: 0 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.composer-quote-label .link-button {
|
||||
margin-left: 0.4em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.composer-quote-notice {
|
||||
margin: 0 0 4px;
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
.composer-counter {
|
||||
margin-left: auto;
|
||||
color: var(--ms-muted-fg);
|
||||
@@ -156,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%;
|
||||
}
|
||||
@@ -286,6 +321,71 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto;
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@
|
||||
}
|
||||
|
||||
.rich-text .mfm .custom-emoji {
|
||||
/* Misskey's emoji width knows no bounds. */
|
||||
max-width: unset !important;
|
||||
/* Wide emoji are supported, but never wider than twice their height. */
|
||||
max-width: calc(var(--emoji-size) * 2);
|
||||
}
|
||||
|
||||
.rich-text:hover .mfm {
|
||||
|
||||
@@ -232,6 +232,40 @@
|
||||
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 */
|
||||
|
||||
.pic-stream-intro {
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
--ms-notice-bg: #ffffcc;
|
||||
--ms-notice-border: #e6c200;
|
||||
--ms-highlight-bg: #ffffcc;
|
||||
/** 4chan's traditional quote colour, used by opt-in Greentexting. */
|
||||
--ms-greentext: #789922;
|
||||
|
||||
/* -------------------------------------------------------------- layout */
|
||||
--ms-page-width: 800px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AppServiceOverrides,
|
||||
type SessionService,
|
||||
type ThemeService,
|
||||
type PreferencesService,
|
||||
} from '$lib/app-services'
|
||||
|
||||
export function account(overrides: Partial<Account> = {}): Account {
|
||||
@@ -88,6 +89,18 @@ export function theme(overrides: Partial<ThemeService> = {}): ThemeService {
|
||||
}
|
||||
}
|
||||
|
||||
export function preferences(
|
||||
overrides: Partial<PreferencesService> = {},
|
||||
): PreferencesService {
|
||||
return {
|
||||
heleneposting: false,
|
||||
greentexting: false,
|
||||
setHeleneposting: () => {},
|
||||
setGreentexting: () => {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test services fail fast on any endpoint that was not explicitly faked. This
|
||||
* turns an accidental network request into a local, descriptive test failure.
|
||||
|
||||
+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