Compare commits

...
8 Commits
Author SHA1 Message Date
Moon.eth c76bab55dd various fixes 2026-08-05 08:04:06 +09:00
Moon.eth 80c6134359 top 8 feature 2026-08-03 16:40:37 +09:00
Moon.eth 7d16969f02 support flash 2026-08-03 15:28:51 +09:00
Moon.eth 1b14d94f6a some copy changes 2026-07-30 20:31:34 +09:00
Moon.eth f735de5185 greentext 2026-07-30 20:22:52 +09:00
Moon.eth 657d517503 quote posts 2026-07-30 19:45:40 +09:00
Moon.eth 49c9c2ba57 refactor and fix all references to emojis 2026-07-30 17:59:15 +09:00
Moon.eth b814d79f19 reacts and custom emoji reacts 2026-07-30 17:38:23 +09:00
81 changed files with 3648 additions and 137 deletions
-3
View File
@@ -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 |
+7
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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

+21 -4
View File
@@ -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>
+34 -1
View File
@@ -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()
})
})
+59 -7
View File
@@ -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>
+202 -3
View File
@@ -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>&mdash; 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 viewers 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))
})
})
+62 -4
View File
@@ -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">
<img src={media.preview_url ?? media.url} alt={media.description ?? ''} />
{#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}
+60
View File
@@ -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({
+131
View File
@@ -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}
+101
View File
@@ -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&hellip;</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()
})
})
+6 -4
View File
@@ -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>
+29
View File
@@ -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()
})
})
+153
View File
@@ -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 accounts 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&hellip;</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>
+31
View File
@@ -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',
)
})
})
+1
View File
@@ -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}
-3
View File
@@ -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>
+19
View File
@@ -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()
})
})
+21
View File
@@ -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>
+38
View File
@@ -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()
})
})
+11 -1
View File
@@ -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>
+51
View File
@@ -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>&gt;first line<br>',
'<a class="mention" href="https://example.test/@alice">@alice</a> &gt;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>&gt;quote</blockquote><pre>&gt;code</pre><ul><li>&gt;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>&gt;plain</p>' },
context: new Map([[APP_SERVICES, testServices()]]),
})
expect(disabled.container.querySelector('.greentext')).not.toBeInTheDocument()
})
})
+7 -2
View File
@@ -6,11 +6,15 @@
* panel blue caption bar (left rail)
* band peach caption bar (main column)
* 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}
+5 -2
View File
@@ -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>
+8 -3
View File
@@ -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>
+15 -5
View File
@@ -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>
+4 -2
View File
@@ -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}`)}
+16 -6
View File
@@ -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)} &middot; 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 ? 'Thats the whole picture stream.' : ''}
/>
+12 -6
View File
@@ -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&hellip;</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>
+116
View File
@@ -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(
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
}
}
+27 -1
View File
@@ -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',
+2
View File
@@ -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.
+14
View File
@@ -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: {},
})
})
})
+78
View File
@@ -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
}
+27
View File
@@ -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()
+46
View File
@@ -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)
})
})
+25
View File
@@ -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
}
+54
View File
@@ -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 &mdash;<strong>Helene</strong></p>',
'<blockquote>A note</blockquote><p>— Helene&nbsp;</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)
})
})
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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('&gt;')) 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)
}
+8 -2
View File
@@ -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,
}
+62
View File
@@ -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',
})
})
})
+57
View File
@@ -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)
}
+62
View File
@@ -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')
})
})
+180
View File
@@ -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.
}
}
+34
View File
@@ -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&rsquo;s always Pleroma&trade;.</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>
+23
View File
@@ -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()
})
})
+57 -8
View File
@@ -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&hellip;</p>
{:else if quoteError}
<p class="error-note" role="alert">
<strong class="error-note-title">The entry cant 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}
+21 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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')
})
})
+1 -1
View File
@@ -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()
+23 -6
View File
@@ -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) ||
'(no text)'}
<EmojiText
text={toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
'(no text)'}
emojis={item.status.emojis}
/>
</a>
</p>
{/if}
+81 -11
View File
@@ -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`}
+83
View File
@@ -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 accounts own image attachments', async () => {
const ownPicture = status({
id: 'picture-entry',
+90
View File
@@ -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&hellip;</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>
+29
View File
@@ -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()
})
})
+37 -2
View File
@@ -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 youve 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>&mdash;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>&gt;</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)}
+47 -3
View File
@@ -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)
})
})
+21 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+34
View File
@@ -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 {
+2
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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)),