Compare commits

..
5 Commits
Author SHA1 Message Date
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
61 changed files with 2262 additions and 111 deletions
-3
View File
@@ -16,9 +16,6 @@ you can host anywhere.
The canonical repository is hosted on Radicle: The canonical repository is hosted on Radicle:
`rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT`. `rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT`.
The backup mirror is
[`https://git.shipoclu.com/moon/plspace.git`](https://git.shipoclu.com/moon/plspace.git).
## What it looks like ## What it looks like
| MySpace | plspace | | MySpace | plspace |
+11 -2
View File
@@ -12,11 +12,13 @@
import Profile from '$routes/Profile.svelte' import Profile from '$routes/Profile.svelte'
import Timeline from '$routes/Timeline.svelte' import Timeline from '$routes/Timeline.svelte'
import StatusPage from '$routes/StatusPage.svelte' import StatusPage from '$routes/StatusPage.svelte'
import Quotes from '$routes/Quotes.svelte'
import Mail from '$routes/Mail.svelte' import Mail from '$routes/Mail.svelte'
import Browse from '$routes/Browse.svelte' import Browse from '$routes/Browse.svelte'
import Search from '$routes/Search.svelte' import Search from '$routes/Search.svelte'
import Login from '$routes/Login.svelte' import Login from '$routes/Login.svelte'
import Settings from '$routes/Settings.svelte' import Settings from '$routes/Settings.svelte'
import About from '$routes/About.svelte'
import Compose from '$routes/Compose.svelte' import Compose from '$routes/Compose.svelte'
import NotFound from '$routes/NotFound.svelte' import NotFound from '$routes/NotFound.svelte'
import type { TimelineKind } from '$lib/api/endpoints' import type { TimelineKind } from '$lib/api/endpoints'
@@ -49,7 +51,7 @@
const route = $derived(router.current) const route = $derived(router.current)
/** Route names that are usable before a server is chosen. */ /** 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'] const TIMELINE_KINDS: readonly TimelineKind[] = ['home', 'public', 'local']
@@ -89,6 +91,8 @@
<Timeline kind="tag" tag={route.params.tag} /> <Timeline kind="tag" tag={route.params.tag} />
{:else if route.name === 'blog.entry'} {:else if route.name === 'blog.entry'}
<StatusPage id={route.params.id} /> <StatusPage id={route.params.id} />
{:else if route.name === 'blog.quotes'}
<Quotes id={route.params.id} />
{:else if route.name === 'mail'} {:else if route.name === 'mail'}
<Mail folder="inbox" /> <Mail folder="inbox" />
{:else if route.name === 'mail.folder'} {:else if route.name === 'mail.folder'}
@@ -98,11 +102,16 @@
{:else if route.name === 'search'} {:else if route.name === 'search'}
<Search q={route.query.get('q') ?? ''} /> <Search q={route.query.get('q') ?? ''} />
{:else if route.name === 'compose'} {: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'} {:else if route.name === 'login'}
<Login /> <Login />
{:else if route.name === 'settings'} {:else if route.name === 'settings'}
<Settings /> <Settings />
{:else if route.name === 'about'}
<About />
{:else} {:else}
<NotFound /> <NotFound />
{/if} {/if}
Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

+7 -3
View File
@@ -6,14 +6,16 @@
* when it's revealed, and the reveal is per-attachment because a single post * when it's revealed, and the reveal is per-attachment because a single post
* can mix flagged and unflagged media. * can mix flagged and unflagged media.
*/ */
import type { MediaAttachment } from '$lib/api/types' import type { CustomEmoji, MediaAttachment } from '$lib/api/types'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
attachments: MediaAttachment[] attachments: MediaAttachment[]
emojis?: CustomEmoji[]
sensitive?: boolean sensitive?: boolean
} }
let { attachments, sensitive = false }: Props = $props() let { attachments, emojis, sensitive = false }: Props = $props()
let revealed = $state<Record<string, boolean>>({}) let revealed = $state<Record<string, boolean>>({})
@@ -81,7 +83,9 @@
{/if} {/if}
{#if media.description} {#if media.description}
<figcaption class="attachment-caption">{media.description}</figcaption> <figcaption class="attachment-caption">
<EmojiText text={media.description} {emojis} />
</figcaption>
{/if} {/if}
</figure> </figure>
+59 -7
View File
@@ -14,13 +14,19 @@
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile' import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html' 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 { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube' import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
import MfmContent from '../common/MfmContent.svelte' import MfmContent from '../common/MfmContent.svelte'
import Attachments from './Attachments.svelte' import Attachments from './Attachments.svelte'
import EmojiReactions from './EmojiReactions.svelte'
import PollView from './PollView.svelte' import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte' import PreviewCardView from './PreviewCardView.svelte'
import QuoteCard from './QuoteCard.svelte'
import YouTubeEmbeds from './YouTubeEmbeds.svelte' import YouTubeEmbeds from './YouTubeEmbeds.svelte'
interface Props { interface Props {
@@ -37,17 +43,26 @@
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props() 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. */ /** The status actually being displayed; a boost renders its target. */
const entry = $derived(status.reblog ?? status) const entry = $derived(status.reblog ?? status)
const booster = $derived(status.reblog ? status.account : null) 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 authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
const handle = $derived(fullHandle(author, session.host)) const handle = $derived(fullHandle(author, session.host))
const permalink = $derived(`#/blog/${entry.id}`) const permalink = $derived(`#/blog/${entry.id}`)
const isMine = $derived(session.me?.id === entry.account.id) const isMine = $derived(session.me?.id === entry.account.id)
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content)) 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 busy = $state(false)
let actionError = $state<string | null>(null) let actionError = $state<string | null>(null)
@@ -144,7 +159,10 @@
> >
{#if booster} {#if booster}
<p class="blog-entry-attribution"> <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> </p>
{/if} {/if}
@@ -180,8 +198,11 @@
<div class="blog-entry-body"> <div class="blog-entry-body">
{#if entry.spoiler_text} {#if entry.spoiler_text}
<details class="content-warning"> <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 <MfmContent
class={quote ? 'quote-post-content' : ''}
html={entry.content} html={entry.content}
emojis={entry.emojis} emojis={entry.emojis}
mentions={entry.mentions} mentions={entry.mentions}
@@ -189,12 +210,13 @@
lang={entry.language} lang={entry.language}
/> />
{#if entry.media_attachments.length > 0} {#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} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} /> <YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details> </details>
{:else} {:else}
<MfmContent <MfmContent
class={quote ? 'quote-post-content' : ''}
html={entry.content} html={entry.content}
emojis={entry.emojis} emojis={entry.emojis}
mentions={entry.mentions} mentions={entry.mentions}
@@ -202,20 +224,25 @@
lang={entry.language} lang={entry.language}
/> />
{#if entry.media_attachments.length > 0} {#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} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} /> <YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if} {/if}
{#if quote}
<QuoteCard {quote} />
{/if}
{#if entry.poll} {#if entry.poll}
<PollView <PollView
poll={entry.poll} poll={entry.poll}
emojis={entry.poll.emojis?.length ? entry.poll.emojis : entry.emojis}
authorId={entry.account.id} authorId={entry.account.id}
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))} onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
/> />
{/if} {/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} /> <PreviewCardView card={entry.card} />
{/if} {/if}
@@ -254,6 +281,26 @@
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span> <span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
</button> </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} {#if entry.url}
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer"> <a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
Original Original
@@ -266,5 +313,10 @@
</button> </button>
{/if} {/if}
</footer> </footer>
<EmojiReactions
status={entry}
onupdate={(updated) => onupdate?.(rewrap(status, updated))}
/>
</div> </div>
</article> </article>
+202 -3
View File
@@ -1,7 +1,7 @@
import { fireEvent, render } from '@testing-library/svelte' import { fireEvent, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services' 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' import BlogEntry from './BlogEntry.svelte'
function renderEntry(overrides: Parameters<typeof status>[0]) { 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))
})
})
+37 -1
View File
@@ -9,10 +9,13 @@
import { untrack } from 'svelte' import { untrack } from 'svelte'
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types' import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import QuoteCard from './QuoteCard.svelte'
interface Props { interface Props {
/** Set to reply to an existing entry. */ /** Set to reply to an existing entry. */
inReplyTo?: Status | null inReplyTo?: Status | null
/** Status attached as a structured quote. */
quote?: Status | null
/** Prefilled body, e.g. the mentions of the entry being replied to. */ /** Prefilled body, e.g. the mentions of the entry being replied to. */
initialText?: string initialText?: string
initialVisibility?: StatusVisibility initialVisibility?: StatusVisibility
@@ -23,6 +26,7 @@
let { let {
inReplyTo = null, inReplyTo = null,
quote = null,
initialText = '', initialText = '',
initialVisibility = 'public', initialVisibility = 'public',
placeholder = 'What are you up to?', placeholder = 'What are you up to?',
@@ -33,6 +37,7 @@
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
// Seeded once from the prop; afterwards the textarea owns the value. // Seeded once from the prop; afterwards the textarea owns the value.
let text = $state(untrack(() => initialText)) let text = $state(untrack(() => initialText))
let quotedStatus = $state<Status | null>(untrack(() => quote))
let warning = $state('') let warning = $state('')
let showWarning = $state(false) let showWarning = $state(false)
let visibility = $state<StatusVisibility>( let visibility = $state<StatusVisibility>(
@@ -73,7 +78,7 @@
remaining >= 0 && remaining >= 0 &&
pollValid && pollValid &&
!(showPoll && attachments.length > 0) && !(showPoll && attachments.length > 0) &&
(text.trim().length > 0 || attachments.length > 0), (text.trim().length > 0 || attachments.length > 0 || quotedStatus !== null),
) )
const POLL_DURATION_PRESETS = [ const POLL_DURATION_PRESETS = [
@@ -201,6 +206,7 @@
multiple: pollMultiple, multiple: pollMultiple,
} }
: undefined, : undefined,
quoted_status_id: quotedStatus?.id,
}) })
text = '' text = ''
warning = '' warning = ''
@@ -209,6 +215,7 @@
showPoll = false showPoll = false
pollOptions = ['', ''] pollOptions = ['', '']
pollMultiple = false pollMultiple = false
quotedStatus = null
onposted?.(created) onposted?.(created)
} catch (cause) { } catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not post that.' error = cause instanceof Error ? cause.message : 'Could not post that.'
@@ -250,6 +257,35 @@
{#if session.signedIn} {#if session.signedIn}
<form class="composer" onsubmit={submit} use:composerInteractions> <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} {#if error}
<p class="error-note" role="alert">{error}</p> <p class="error-note" role="alert">{error}</p>
{/if} {/if}
+25
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 () => { it('composes a multiple-choice poll without a backend', async () => {
const postStatus = vi.fn().mockResolvedValue(status()) const postStatus = vi.fn().mockResolvedValue(status())
const services = testServices({ 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}
+6 -4
View File
@@ -1,17 +1,19 @@
<script lang="ts"> <script lang="ts">
/** Mastodon/Pleroma poll choices, voting and results. */ /** Mastodon/Pleroma poll choices, voting and results. */
import { untrack } from 'svelte' 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 { useAppServices } from '$lib/app-services'
import { formatCount } from '$lib/util/profile' import { formatCount } from '$lib/util/profile'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
poll: Poll poll: Poll
emojis?: CustomEmoji[]
authorId?: string authorId?: string
onupdate?: (poll: Poll) => void onupdate?: (poll: Poll) => void
} }
let { poll, authorId, onupdate }: Props = $props() let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
function pollSignature(value: Poll): string { function pollSignature(value: Poll): string {
@@ -113,7 +115,7 @@
{#if currentPoll.own_votes?.includes(index)} {#if currentPoll.own_votes?.includes(index)}
<span class="poll-own-vote" aria-label="Your vote"></span> <span class="poll-own-vote" aria-label="Your vote"></span>
{/if} {/if}
{option.title} <EmojiText text={option.title} {emojis} />
</span> </span>
<span class="poll-option-share"> <span class="poll-option-share">
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`} {option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
@@ -137,7 +139,7 @@
{:else} {:else}
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} /> <input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
{/if} {/if}
<span>{option.title}</span> <EmojiText text={option.title} {emojis} />
</label> </label>
{/each} {/each}
</fieldset> </fieldset>
+29
View File
@@ -91,4 +91,33 @@ describe('PollView', () => {
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login') expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument() 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 { ApiClient } from '$lib/api/client'
import type { Notification } from '$lib/api/types' import type { Notification } from '$lib/api/types'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { accountForNotification } from '$lib/util/heleneposting'
import { displayNameOf } from '$lib/util/profile'
import { import {
NOTIFICATION_DISMISS_AFTER_MS, NOTIFICATION_DISMISS_AFTER_MS,
NOTIFICATION_POLL_INTERVAL_MS, NOTIFICATION_POLL_INTERVAL_MS,
NotificationTracker, NotificationTracker,
emojiForNotification,
presentNotification, presentNotification,
type NotificationPresentation, type NotificationPresentation,
} from '$lib/notifications' } from '$lib/notifications'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
pollIntervalMs?: number pollIntervalMs?: number
@@ -33,7 +37,7 @@
maxToasts = 4, maxToasts = 4,
}: Props = $props() }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session, preferences } = useAppServices()
const tracker = new NotificationTracker() const tracker = new NotificationTracker()
const dismissTimers = new Map<string, number>() const dismissTimers = new Map<string, number>()
@@ -138,6 +142,8 @@
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite"> <aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
{#each toasts as toast (toast.notification.id)} {#each toasts as toast (toast.notification.id)}
{@const reaction = emojiForNotification(toast.notification)}
{@const actor = accountForNotification(toast.notification, preferences.heleneposting)}
<a <a
class="notification-toast" class="notification-toast"
href={toast.presentation.href} href={toast.presentation.href}
@@ -145,14 +151,26 @@
data-account={toast.notification.account.acct} data-account={toast.notification.account.acct}
onclick={() => dismiss(toast.notification.id)} 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-body">
<span class="notification-toast-message"> <span class="notification-toast-message">
<strong>{toast.presentation.actor}</strong> <strong>
<EmojiText
text={displayNameOf(actor)}
emojis={toast.notification.account.emojis}
/>
</strong>
{toast.presentation.message} {toast.presentation.message}
{#if reaction}
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
{/if}
</span> </span>
{#if toast.presentation.excerpt} {#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} {/if}
</span> </span>
</a> </a>
@@ -71,4 +71,37 @@ describe('NotificationToasts', () => {
}) })
expect(view.queryByText('A new mention')).not.toBeInTheDocument() 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="#/browse">Browse</a>
<a href="#/search">Search</a> <a href="#/search">Search</a>
<a href="#/settings">Settings</a> <a href="#/settings">Settings</a>
<a href="#/about">About plspace</a>
{#if domain} {#if domain}
<a href={`https://${session.host}/about`} target="_blank" rel="noopener noreferrer">About this server</a> <a href={`https://${session.host}/about`} target="_blank" rel="noopener noreferrer">About this server</a>
{/if} {/if}
+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. * it intentionally does not parse raw `$[...]` source syntax.
*/ */
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types' import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { renderMfmHtml } from '$lib/util/mfm' import { renderMfmHtml } from '$lib/util/mfm'
interface Props { interface Props {
@@ -33,8 +34,17 @@
scale = false, scale = false,
}: Props = $props() }: Props = $props()
const { preferences } = useAppServices()
const rendered = $derived( const rendered = $derived(
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }), renderMfmHtml(html, {
emojis,
mentions,
tags,
inline,
pause,
scale,
greentext: preferences.greentexting,
}),
) )
</script> </script>
+51
View File
@@ -1,5 +1,7 @@
import { render } from '@testing-library/svelte' import { render } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import { preferences, testServices } from '$test/fixtures'
import MfmContent from './MfmContent.svelte' import MfmContent from './MfmContent.svelte'
function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement { function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
@@ -70,4 +72,53 @@ describe('MfmContent', () => {
'https://example.test/emoji/party.png', '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) * panel blue caption bar (left rail)
* band peach caption bar (main column) * band peach caption bar (main column)
* plain no chrome, just the heading * plain no chrome, just the heading
*/ */
import type { Snippet } from 'svelte' import type { Snippet } from 'svelte'
import type { CustomEmoji } from '$lib/api/types'
import EmojiText from './EmojiText.svelte'
interface Props { interface Props {
title?: string title?: string
/** Custom emoji available to user-derived titles. */
titleEmojis?: CustomEmoji[]
variant?: 'panel' | 'band' | 'plain' variant?: 'panel' | 'band' | 'plain'
/** Right-aligned link in the caption bar, e.g. "[view all]". */ /** Right-aligned link in the caption bar, e.g. "[view all]". */
action?: Snippet action?: Snippet
@@ -23,6 +27,7 @@
let { let {
title, title,
titleEmojis,
variant = 'panel', variant = 'panel',
action, action,
flush = false, flush = false,
@@ -38,7 +43,7 @@
<section class="module {variantClass} {extraClass}" data-variant={variant}> <section class="module {variantClass} {extraClass}" data-variant={variant}>
{#if title} {#if title}
<h2 class="module-header"> <h2 class="module-header">
<span class="module-header-title">{title}</span> <EmojiText class="module-header-title" text={title} emojis={titleEmojis} />
{#if action} {#if action}
<span class="module-header-action">{@render action()}</span> <span class="module-header-action">{@render action()}</span>
{/if} {/if}
+5 -2
View File
@@ -10,6 +10,7 @@
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { displayNameOf } from '$lib/util/profile' import { displayNameOf } from '$lib/util/profile'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
account: Account account: Account
@@ -73,7 +74,7 @@
} }
</script> </script>
<Module title={`Contacting ${firstName}`}> <Module title={`Contacting ${firstName}`} titleEmojis={account.emojis}>
{#if error} {#if error}
<p class="error-note" role="alert">{error}</p> <p class="error-note" role="alert">{error}</p>
{/if} {/if}
@@ -137,6 +138,8 @@
</ul> </ul>
{#if relationship?.followed_by && !isSelf} {#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} {/if}
</Module> </Module>
+8 -3
View File
@@ -7,23 +7,28 @@
* an unverified link that looks verified is a phishing surface. * an unverified link that looks verified is a phishing surface.
*/ */
import type { ProfileField } from '$lib/util/profile' import type { ProfileField } from '$lib/util/profile'
import type { CustomEmoji } from '$lib/api/types'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
title: string title: string
fields: ProfileField[] fields: ProfileField[]
emojis?: CustomEmoji[]
} }
let { title, fields }: Props = $props() let { title, fields, emojis }: Props = $props()
</script> </script>
{#if fields.length > 0} {#if fields.length > 0}
<Module {title} flush> <Module {title} titleEmojis={emojis} flush>
<table class="data-table details-table"> <table class="data-table details-table">
<tbody> <tbody>
{#each fields as field, index (`${field.name}:${index}`)} {#each fields as field, index (`${field.name}:${index}`)}
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}> <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'}> <td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
{#if field.verified} {#if field.verified}
<span class="verified-mark" title="Ownership of this link is verified"></span> <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 * accounts often return an empty list rather than an error, so the count and
* the grid are allowed to disagree; the count is authoritative. * 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 { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
title: string title: string
/** The subject, used in "Tom has 527 friends." */ /** The subject, used in "Tom has 527 friends." */
ownerName: string ownerName: string
ownerEmojis?: CustomEmoji[]
friends: Account[] friends: Account[]
total: number total: number
viewAllHref: string viewAllHref: string
@@ -29,6 +31,7 @@
let { let {
title, title,
ownerName, ownerName,
ownerEmojis,
friends, friends,
total, total,
viewAllHref, viewAllHref,
@@ -39,7 +42,7 @@
}: Props = $props() }: Props = $props()
</script> </script>
<Module {title} variant="band"> <Module {title} titleEmojis={ownerEmojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={viewAllHref}>[view all]</a> <a href={viewAllHref}>[view all]</a>
{/snippet} {/snippet}
@@ -47,10 +50,13 @@
<!-- Never render a withheld count as "0 friends" — that reports a privacy <!-- Never render a withheld count as "0 friends" — that reports a privacy
setting as a fact about the person. --> setting as a fact about the person. -->
{#if countHidden} {#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} {:else}
<p class="friend-count"> <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'}. friend{total === 1 ? '' : 's'}.
</p> </p>
{/if} {/if}
@@ -66,7 +72,11 @@
{#each friends as friend (friend.id)} {#each friends as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}> <li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}> <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" /> <Avatar account={friend} plain size="friend" class="friend-card-photo" />
</a> </a>
</li> </li>
+4 -2
View File
@@ -6,18 +6,20 @@
* Mastodon account gets a compact box rather than six empty rows. * Mastodon account gets a compact box rather than six empty rows.
*/ */
import type { InterestEntry } from '$lib/util/profile' import type { InterestEntry } from '$lib/util/profile'
import type { CustomEmoji } from '$lib/api/types'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
interface Props { interface Props {
title: string title: string
interests: InterestEntry[] interests: InterestEntry[]
emojis?: CustomEmoji[]
} }
let { title, interests }: Props = $props() let { title, interests, emojis }: Props = $props()
</script> </script>
{#if interests.length > 0} {#if interests.length > 0}
<Module {title} flush> <Module {title} titleEmojis={emojis} flush>
<table class="data-table interests-table"> <table class="data-table interests-table">
<tbody> <tbody>
{#each interests as entry, index (`${entry.row}:${index}`)} {#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 * stream. Videos/audio stay in the Blog; reposted pictures are not somebody's
* own Pics. * 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 type { Feed } from '$lib/stores/feed.svelte'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { stampDate } from '$lib/util/time' import { stampDate } from '$lib/util/time'
import Pager from '../common/Pager.svelte' import Pager from '../common/Pager.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
feed: Feed<Status> feed: Feed<Status>
ownerName: string ownerName: string
ownerEmojis?: CustomEmoji[]
} }
interface Picture { interface Picture {
@@ -22,7 +24,7 @@
caption: string caption: string
} }
let { feed, ownerName }: Props = $props() let { feed, ownerName, ownerEmojis }: Props = $props()
let revealed = $state<Record<string, boolean>>({}) let revealed = $state<Record<string, boolean>>({})
const pictures = $derived.by<Picture[]>(() => const pictures = $derived.by<Picture[]>(() =>
@@ -46,7 +48,8 @@
</script> </script>
<p class="pic-stream-intro"> <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> </p>
{#if !feed.initialized && feed.loading} {#if !feed.initialized && feed.loading}
@@ -97,10 +100,17 @@
<figcaption class="pic-card-caption"> <figcaption class="pic-card-caption">
{#if hidden} {#if hidden}
<span class="pic-card-description"> <span class="pic-card-description">
{picture.status.spoiler_text || 'Sensitive picture'} <EmojiText
text={picture.status.spoiler_text || 'Sensitive picture'}
emojis={picture.status.emojis}
/>
</span> </span>
{:else if picture.caption} {: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} {/if}
<a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}> <a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}>
Posted {stampDate(picture.status.created_at)} &middot; view entry Posted {stampDate(picture.status.created_at)} &middot; view entry
@@ -115,6 +125,6 @@
<Pager <Pager
{feed} {feed}
label="View More Pictures" 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.' : ''} 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 type { ProfileView } from '$lib/util/profile'
import { displayNameOf, fullHandle } 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 { relativeTime, shortDate, yearsSince } from '$lib/util/time'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
profile: ProfileView profile: ProfileView
@@ -22,7 +22,6 @@
const { session } = useAppServices() const { session } = useAppServices()
const account = $derived(profile.account) const account = $derived(profile.account)
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
const handle = $derived(fullHandle(account, session.host)) const handle = $derived(fullHandle(account, session.host))
const accountAge = $derived(profile.age ?? yearsSince(account.created_at)) const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
const photo = $derived(account.avatar || account.avatar_static) const photo = $derived(account.avatar || account.avatar_static)
@@ -41,12 +40,16 @@
</div> </div>
<div class="profile-vitals-wrap"> <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"> <dl class="profile-vitals">
{#if profile.gender} {#if profile.gender}
<dt>Gender</dt> <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}
{#if accountAge !== null} {#if accountAge !== null}
@@ -61,7 +64,9 @@
{#if profile.location} {#if profile.location}
<dt>Location</dt> <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} {/if}
<dt>Last active</dt> <dt>Last active</dt>
@@ -78,7 +83,8 @@
{#if profile.mood} {#if profile.mood}
<p class="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> </p>
{/if} {/if}
+82
View File
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiClient } from './client' import { ApiClient } from './client'
import { import {
fetchNotifications, fetchNotifications,
fetchQuotes,
postStatus, postStatus,
setEmojiReaction,
updateProfileFields, updateProfileFields,
updatePublicProfile, updatePublicProfile,
votePoll, votePoll,
@@ -152,6 +154,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', () => { describe('fetchNotifications', () => {
it('defensively applies requested types when a server ignores the filter', async () => { it('defensively applies requested types when a server ignores the filter', async () => {
vi.stubGlobal( vi.stubGlobal(
+41
View File
@@ -283,6 +283,7 @@ export interface ComposeOptions {
expires_in: number expires_in: number
multiple: boolean multiple: boolean
} }
quoted_status_id?: string
} }
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> { 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.media_ids?.length) body.media_ids = options.media_ids
if (options.language) body.language = options.language if (options.language) body.language = options.language
if (options.poll) body.poll = options.poll 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) 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> { export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`) 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}`) 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> { export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices }) return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
} }
+60
View File
@@ -163,6 +163,47 @@ export interface Poll {
own_votes?: number[] 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 { export interface Status {
id: string id: string
uri: string uri: string
@@ -183,6 +224,7 @@ export interface Status {
replies_count: number replies_count: number
reblogs_count: number reblogs_count: number
favourites_count: number favourites_count: number
quotes_count?: number
media_attachments: MediaAttachment[] media_attachments: MediaAttachment[]
mentions: StatusMention[] mentions: StatusMention[]
@@ -190,6 +232,12 @@ export interface Status {
emojis: CustomEmoji[] emojis: CustomEmoji[]
card?: PreviewCard | null card?: PreviewCard | null
poll?: Poll | 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 application?: { name: string; website?: string | null } | null
reblog: Status | null reblog: Status | null
@@ -205,6 +253,13 @@ export interface Status {
conversation_id?: number conversation_id?: number
content?: Record<string, string> content?: Record<string, string>
spoiler_text?: 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 +288,8 @@ export type NotificationType =
| 'favourite' | 'favourite'
| 'poll' | 'poll'
| 'update' | 'update'
| 'quote'
| 'quoted_update'
| 'admin.sign_up' | 'admin.sign_up'
| 'admin.report' | 'admin.report'
| 'pleroma:emoji_reaction' | 'pleroma:emoji_reaction'
@@ -244,6 +301,9 @@ export interface Notification {
created_at: string created_at: string
account: Account account: Account
status?: Status | null status?: Status | null
/** Pleroma/Akkoma emoji-reaction notification payload. */
emoji?: string | null
emoji_url?: string | null
} }
export interface Context { export interface Context {
+12
View File
@@ -12,6 +12,7 @@ import * as endpointImplementations from './api/endpoints'
import { router as defaultRouter, type RouteMatch } from './router.svelte' import { router as defaultRouter, type RouteMatch } from './router.svelte'
import { session as defaultSession } from './stores/session.svelte' import { session as defaultSession } from './stores/session.svelte'
import { theme as defaultTheme } from './stores/theme.svelte' import { theme as defaultTheme } from './stores/theme.svelte'
import { preferences as defaultPreferences } from './stores/preferences.svelte'
export interface SessionService { export interface SessionService {
host: string host: string
@@ -45,10 +46,18 @@ export interface ThemeService {
clearProfileCss(): void clearProfileCss(): void
} }
export interface PreferencesService {
heleneposting: boolean
greentexting: boolean
setHeleneposting(enabled: boolean): void
setGreentexting(enabled: boolean): void
}
export interface AppServices { export interface AppServices {
session: SessionService session: SessionService
router: RouterService router: RouterService
theme: ThemeService theme: ThemeService
preferences: PreferencesService
endpoints: typeof endpointImplementations endpoints: typeof endpointImplementations
} }
@@ -58,6 +67,7 @@ export const defaultAppServices: AppServices = {
session: defaultSession, session: defaultSession,
router: defaultRouter, router: defaultRouter,
theme: defaultTheme, theme: defaultTheme,
preferences: defaultPreferences,
endpoints: endpointImplementations, endpoints: endpointImplementations,
} }
@@ -70,6 +80,7 @@ export interface AppServiceOverrides {
session?: SessionService session?: SessionService
router?: RouterService router?: RouterService
theme?: ThemeService theme?: ThemeService
preferences?: PreferencesService
endpoints?: Partial<typeof endpointImplementations> endpoints?: Partial<typeof endpointImplementations>
} }
@@ -79,6 +90,7 @@ export function createAppServices(overrides: AppServiceOverrides = {}): AppServi
session: overrides.session ?? defaultAppServices.session, session: overrides.session ?? defaultAppServices.session,
router: overrides.router ?? defaultAppServices.router, router: overrides.router ?? defaultAppServices.router,
theme: overrides.theme ?? defaultAppServices.theme, theme: overrides.theme ?? defaultAppServices.theme,
preferences: overrides.preferences ?? defaultAppServices.preferences,
endpoints: { ...defaultAppServices.endpoints, ...overrides.endpoints }, 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 { toPlainText } from './util/html'
import { displayNameOf, profilePath } from './util/profile' import { displayNameOf, profilePath } from './util/profile'
@@ -53,6 +53,30 @@ export interface NotificationPresentation {
href: string 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> = { const MESSAGE: Record<string, string> = {
mention: 'mentioned you in an entry', mention: 'mentioned you in an entry',
status: 'posted a new entry', status: 'posted a new entry',
@@ -62,6 +86,8 @@ const MESSAGE: Record<string, string> = {
favourite: 'gave your entry kudos', favourite: 'gave your entry kudos',
poll: 'has a poll that just ended', poll: 'has a poll that just ended',
update: 'edited an entry', update: 'edited an entry',
quote: 'quoted your entry',
quoted_update: 'edited an entry you quoted',
'pleroma:emoji_reaction': 'reacted to your entry', 'pleroma:emoji_reaction': 'reacted to your entry',
'admin.sign_up': 'joined the server', 'admin.sign_up': 'joined the server',
'admin.report': 'was included in a report', 'admin.report': 'was included in a report',
+2
View File
@@ -25,12 +25,14 @@ const ROUTES: RoutePattern[] = [
{ name: 'home', pattern: '/' }, { name: 'home', pattern: '/' },
{ name: 'login', pattern: '/login' }, { name: 'login', pattern: '/login' },
{ name: 'settings', pattern: '/settings' }, { name: 'settings', pattern: '/settings' },
{ name: 'about', pattern: '/about' },
{ name: 'browse', pattern: '/browse' }, { name: 'browse', pattern: '/browse' },
{ name: 'search', pattern: '/search' }, { name: 'search', pattern: '/search' },
{ name: 'mail', pattern: '/mail' }, { name: 'mail', pattern: '/mail' },
{ name: 'mail.folder', pattern: '/mail/:folder' }, { name: 'mail.folder', pattern: '/mail/:folder' },
{ name: 'timeline', pattern: '/timeline/:kind' }, { name: 'timeline', pattern: '/timeline/:kind' },
{ name: 'tag', pattern: '/tag/:tag' }, { name: 'tag', pattern: '/tag/:tag' },
{ name: 'blog.quotes', pattern: '/blog/:id/quotes' },
{ name: 'blog.entry', pattern: '/blog/:id' }, { name: 'blog.entry', pattern: '/blog/:id' },
{ name: 'compose', pattern: '/compose' }, { name: 'compose', pattern: '/compose' },
// Account routes come last: `:acct` is greedy enough to shadow the others. // 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')).not.toThrow()
expect(parseHash('#/@broken%ZZ').name).toBe('notfound') 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: {},
})
})
}) })
+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()
+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 * 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. * 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 { function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
if (!emojis?.length) return html if (!emojis?.length) return html
const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji])) 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 replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => {
const emoji = table.get(shortcode) const emoji = table.get(shortcode)
if (!emoji) return whole 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, shortcode,
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />` )}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
}) })
@@ -213,7 +224,12 @@ export function toPlainText(source: string | null | undefined): string {
return (container.textContent ?? '').replace(/\s+/g, ' ').trim() 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 { 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 pause?: boolean
/** Scale effects against the surrounding custom-emoji size. */ /** Scale effects against the surrounding custom-emoji size. */
scale?: boolean scale?: boolean
/** Colour visual lines whose prose starts with `>`, following Pleroma-FE. */
greentext?: boolean
} }
const LOOPING_OPERATORS = new Set([ const LOOPING_OPERATORS = new Set([
@@ -27,6 +29,163 @@ const LOOPING_OPERATORS = new Set([
'rainbow', '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 { function numberAttribute(element: Element, name: string, fallback: number): number {
const parsed = Number.parseFloat(element.getAttribute(name) ?? '') const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
@@ -173,5 +332,6 @@ export function renderMfmHtml(
source: string | null | undefined, source: string | null | undefined,
options: MfmRenderOptions = {}, options: MfmRenderOptions = {},
): string { ): string {
return enhanceMfmHtml(renderHtml(source, options), options) const html = enhanceGreentextHtml(renderHtml(source, options), options.greentext === true)
return enhanceMfmHtml(html, options)
} }
+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)
}
+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"> <script lang="ts">
/** A full-page composer, reachable from the nav and from "Send Message". */ /** 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 { useAppServices } from '$lib/app-services'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
@@ -7,16 +9,53 @@
interface Props { interface Props {
/** Handle to address the entry to, from `?to=`. */ /** Handle to address the entry to, from `?to=`. */
to?: string 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(/^@/, '')} ` : '') 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> </script>
<div class="page compose-page"> <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} {#if to}
<p class="page-subtitle"> <p class="page-subtitle">
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
@@ -25,16 +64,26 @@
{/if} {/if}
<div class="layout--single"> <div class="layout--single">
<Module title={to ? 'New message' : 'New entry'}> <Module title={quoteId ? 'New quote' : to ? 'New message' : 'New entry'}>
{#key to} {#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 <Composer
{quote}
initialText={prefill} initialText={prefill}
initialVisibility={to ? 'direct' : 'public'} initialVisibility={quote?.visibility === 'private' ? 'private' : to ? 'direct' : 'public'}
placeholder={to ? 'Say something…' : 'What are you up to?'} placeholder={quote ? 'Add a comment, or post the quote by itself…' : to ? 'Say something…' : 'What are you up to?'}
submitLabel={to ? 'Send' : 'Post Entry'} submitLabel={quote ? 'Post Quote' : to ? 'Send' : 'Post Entry'}
onposted={(status) => router.go(`#/blog/${status.id}`)} onposted={(status) => router.go(`#/blog/${status.id}`)}
/> />
{/key} {/key}
{/if}
</Module> </Module>
{#if !session.signedIn} {#if !session.signedIn}
+21 -3
View File
@@ -1,7 +1,7 @@
import { render } from '@testing-library/svelte' import { render, waitFor } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services' 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' import Compose from './Compose.svelte'
describe('Compose route', () => { describe('Compose route', () => {
@@ -22,4 +22,22 @@ describe('Compose route', () => {
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ') expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ')
expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct') 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()
})
}) })
+29 -10
View File
@@ -15,17 +15,19 @@
instanceThumbnail, instanceThumbnail,
} from '$lib/api/endpoints' } from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile' 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 { relativeTime, shortDate, stampDate } from '$lib/util/time'
import { useTimelineRefresh } from '$lib/timeline-refresh' import { useTimelineRefresh } from '$lib/timeline-refresh'
import { reconcileRefreshItems } from '$lib/stores/feed.svelte' import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import RichText from '$components/common/RichText.svelte' import RichText from '$components/common/RichText.svelte'
import MfmContent from '$components/common/MfmContent.svelte' import MfmContent from '$components/common/MfmContent.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
const { endpoints, session } = useAppServices() const { endpoints, session, preferences } = useAppServices()
const timelineRefresh = useTimelineRefresh() const timelineRefresh = useTimelineRefresh()
let friendStatus = $state<Status[]>([]) let friendStatus = $state<Status[]>([])
@@ -217,7 +219,11 @@
</div> </div>
{:else} {:else}
<h1 class="page-title"> <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> </h1>
{#if me} {#if me}
<p class="page-subtitle"> <p class="page-subtitle">
@@ -243,7 +249,9 @@
<Avatar account={me} size="friend" /> <Avatar account={me} size="friend" />
</p> </p>
<p class="center"> <p class="center">
<a href={profilePath(me)}>{displayNameOf(me)}</a> <a href={profilePath(me)}>
<EmojiText text={displayNameOf(me)} emojis={me.emojis} />
</a>
</p> </p>
<p class="center muted"> <p class="center muted">
Profile views: {formatCount(me.statuses_count)} entries Profile views: {formatCount(me.statuses_count)} entries
@@ -323,14 +331,15 @@
<ul class="status-line-list"> <ul class="status-line-list">
{#each friendStatus as status (status.id)} {#each friendStatus as status (status.id)}
{@const entry = status.reblog ?? status} {@const entry = status.reblog ?? status}
{@const author = accountForStatus(entry, preferences.heleneposting)}
<li class="status-line" data-account={entry.account.acct}> <li class="status-line" data-account={entry.account.acct}>
<Avatar account={entry.account} /> <Avatar account={author} />
<div class="status-line-body"> <div class="status-line-body">
<a class="status-line-author" href={profilePath(entry.account)}> <a class="status-line-author" href={profilePath(entry.account)}>
{displayNameOf(entry.account)} <EmojiText text={displayNameOf(author)} emojis={entry.account.emojis} />
</a> </a>
<MfmContent <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} emojis={entry.emojis}
mentions={entry.mentions} mentions={entry.mentions}
tags={entry.tags} tags={entry.tags}
@@ -374,14 +383,20 @@
<tbody> <tbody>
{#each bulletins as status (status.id)} {#each bulletins as status (status.id)}
{@const entry = status.reblog ?? status} {@const entry = status.reblog ?? status}
{@const author = accountForStatus(entry, preferences.heleneposting)}
<tr> <tr>
<td class="bulletin-from"> <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>
<td class="bulletin-date">{stampDate(entry.created_at)}</td> <td class="bulletin-date">{stampDate(entry.created_at)}</td>
<td class="bulletin-subject"> <td class="bulletin-subject">
<a href={`#/blog/${entry.id}`}> <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> </a>
</td> </td>
</tr> </tr>
@@ -407,7 +422,11 @@
{#each following as friend (friend.id)} {#each following as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}> <li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}> <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 <img
class="friend-card-photo" class="friend-card-photo"
src={friend.avatar_static || friend.avatar} src={friend.avatar_static || friend.avatar}
+1 -1
View File
@@ -25,7 +25,7 @@
* anonymous timeline reads; suggesting one of those hands a first-time * anonymous timeline reads; suggesting one of those hands a first-time
* visitor an empty page. Re-check before adding to this list. * 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> { async function signIn(event: SubmitEvent): Promise<void> {
event.preventDefault() event.preventDefault()
+23 -6
View File
@@ -13,10 +13,13 @@
import { Feed } from '$lib/stores/feed.svelte' import { Feed } from '$lib/stores/feed.svelte'
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile' import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { accountForNotification } from '$lib/util/heleneposting'
import { emojiForNotification } from '$lib/notifications'
import { stampDate } from '$lib/util/time' import { stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Pager from '$components/common/Pager.svelte' import Pager from '$components/common/Pager.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
interface Props { interface Props {
folder?: string folder?: string
@@ -24,7 +27,7 @@
let { folder = 'inbox' }: Props = $props() let { folder = 'inbox' }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session, preferences } = useAppServices()
interface Folder { interface Folder {
key: string key: string
label: string label: string
@@ -59,6 +62,8 @@
poll: 'closed a poll you voted in', poll: 'closed a poll you voted in',
status: 'posted a new entry', status: 'posted a new entry',
update: 'edited an entry you interacted with', 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', 'pleroma:emoji_reaction': 'reacted to your entry',
} }
@@ -160,7 +165,9 @@
</td> </td>
<td> <td>
<strong> <strong>
<a href={profilePath(account)}>{displayNameOf(account)}</a> <a href={profilePath(account)}>
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
</a>
</strong> </strong>
wants to be your friend! wants to be your friend!
<div class="person-row-handle">{fullHandle(account, session.host)}</div> <div class="person-row-handle">{fullHandle(account, session.host)}</div>
@@ -205,23 +212,33 @@
</thead> </thead>
<tbody> <tbody>
{#each notifications.items as item (item.id)} {#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}> <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-date">{stampDate(item.created_at)}</td>
<td class="mail-table-from"> <td class="mail-table-from">
<a href={profilePath(item.account)}> <a href={profilePath(item.account)}>
<Avatar account={item.account} plain /> <Avatar account={actor} plain />
</a> </a>
</td> </td>
<td class="mail-table-subject"> <td class="mail-table-subject">
<strong> <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> </strong>
{VERB[item.type] ?? item.type} {VERB[item.type] ?? item.type}
{#if reaction}
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
{/if}
{#if item.status} {#if item.status}
<p class="mail-table-excerpt"> <p class="mail-table-excerpt">
<a href={`#/blog/${item.status.id}`}> <a href={`#/blog/${item.status.id}`}>
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) || <EmojiText
'(no text)'} text={toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
'(no text)'}
emojis={item.status.emojis}
/>
</a> </a>
</p> </p>
{/if} {/if}
+34 -11
View File
@@ -25,6 +25,7 @@
} from '$lib/util/profile' } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte' import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
import ContactBox from '$components/profile/ContactBox.svelte' import ContactBox from '$components/profile/ContactBox.svelte'
import InterestsTable from '$components/profile/InterestsTable.svelte' import InterestsTable from '$components/profile/InterestsTable.svelte'
@@ -204,7 +205,9 @@
{error} {error}
</p> </p>
{:else if account && profile} {: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} {#if account.moved}
<p class="profile-moved"> <p class="profile-moved">
@@ -229,10 +232,18 @@
</p> </p>
</Module> </Module>
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} /> <InterestsTable
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} /> 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"> <table class="data-table stats-table">
<tbody> <tbody>
<tr> <tr>
@@ -275,6 +286,7 @@
<FriendSpace <FriendSpace
title={`${firstName}'s Friend Space`} title={`${firstName}'s Friend Space`}
ownerName={firstName} ownerName={firstName}
ownerEmojis={account.emojis}
{friends} {friends}
total={account.followers_count} total={account.followers_count}
viewAllHref={`#/@${account.acct}`} viewAllHref={`#/@${account.acct}`}
@@ -283,7 +295,7 @@
{countHidden} {countHidden}
/> />
{:else if view === 'blog'} {:else if view === 'blog'}
<Module title={`${firstName}'s Blog`} variant="band"> <Module title={`${firstName}'s Blog`} titleEmojis={account.emojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={base}>[Back to Profile]</a> <a href={base}>[Back to Profile]</a>
{/snippet} {/snippet}
@@ -309,12 +321,12 @@
/> />
</Module> </Module>
{:else if view === 'pics'} {:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band"> <Module title={`${firstName}'s Pics`} titleEmojis={account.emojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={base}>[Back to Profile]</a> <a href={base}>[Back to Profile]</a>
{/snippet} {/snippet}
<PicStream feed={entries} ownerName={firstName} /> <PicStream feed={entries} ownerName={firstName} ownerEmojis={account.emojis} />
</Module> </Module>
{:else} {:else}
<!-- <!--
@@ -323,7 +335,11 @@
posts of media push the Blurbs and Friend Space off the bottom, posts of media push the Blurbs and Friend Space off the bottom,
which is the wrong shape for a profile. 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()} {#snippet action()}
<a href={`${base}/blog`}>[View Blog]</a> <a href={`${base}/blog`}>[View Blog]</a>
{/snippet} {/snippet}
@@ -337,7 +353,11 @@
{#each entries.items.slice(0, 6) as status (status.id)} {#each entries.items.slice(0, 6) as status (status.id)}
{@const entry = status.reblog ?? status} {@const entry = status.reblog ?? status}
<li class="entry-teaser" data-status-id={entry.id}> <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> <a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
</li> </li>
{/each} {/each}
@@ -348,7 +368,7 @@
{/if} {/if}
</Module> </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> <h3 class="section-heading">About me:</h3>
{#if profile.about} {#if profile.about}
<div class="rich-text blurb-body"> <div class="rich-text blurb-body">
@@ -356,7 +376,9 @@
{@html profile.about} {@html profile.about}
</div> </div>
{:else} {: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} {/if}
<h3 class="section-heading">Who I'd like to meet:</h3> <h3 class="section-heading">Who I'd like to meet:</h3>
@@ -375,6 +397,7 @@
<FriendSpace <FriendSpace
title={`${firstName}'s Friend Space`} title={`${firstName}'s Friend Space`}
ownerName={firstName} ownerName={firstName}
ownerEmojis={account.emojis}
friends={friends.slice(0, FRIEND_PREVIEW)} friends={friends.slice(0, FRIEND_PREVIEW)}
total={account.followers_count} total={account.followers_count}
viewAllHref={`#/@${account.acct}/friends`} viewAllHref={`#/@${account.acct}/friends`}
+45
View File
@@ -7,6 +7,51 @@ import { account, deferred, session, status, testServices, theme } from '$test/f
import Profile from './Profile.svelte' import Profile from './Profile.svelte'
describe('Profile', () => { 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 () => { it('does not apply profile CSS after it has unmounted', async () => {
const lookup = deferred<Account>() const lookup = deferred<Account>()
const applyProfileCss = vi.fn() const applyProfileCss = vi.fn()
+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 { instanceDomain } from '$lib/api/endpoints'
import { displayNameOf, profilePath } from '$lib/util/profile' import { displayNameOf, profilePath } from '$lib/util/profile'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte' import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte'
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte' import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
const { session, theme } = useAppServices() const { session, theme, preferences } = useAppServices()
let draft = $state(theme.viewerCss) let draft = $state(theme.viewerCss)
let saved = $state(false) let saved = $state(false)
@@ -65,6 +66,7 @@
['.blog-entry[data-visibility="private"]', 'Friends-only entries'], ['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
['.blog-entry[data-boosted="true"]', 'Reposts'], ['.blog-entry[data-boosted="true"]', 'Reposts'],
['.youtube-attachment, .youtube-embed', 'YouTube embeds'], ['.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'], ['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons youve activated'],
['.comment[data-depth="2"]', 'Comments by nesting depth'], ['.comment[data-depth="2"]', 'Comments by nesting depth'],
], ],
@@ -97,7 +99,9 @@
{#if session.signedIn && session.me} {#if session.signedIn && session.me}
<p> <p>
Signed in as 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>. on <strong>{domain}</strong>.
</p> </p>
<p class="field-row"> <p class="field-row">
@@ -123,6 +127,37 @@
<PublicProfileEditor /> <PublicProfileEditor />
</Module> </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"> <Module title="Pick a layout" variant="band">
<ul class="preset-list"> <ul class="preset-list">
{#each PRESETS as preset (preset.id)} {#each PRESETS as preset (preset.id)}
+47 -3
View File
@@ -1,7 +1,7 @@
import { render } from '@testing-library/svelte' import { fireEvent, render } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services' import { APP_SERVICES } from '$lib/app-services'
import { testServices } from '$test/fixtures' import { preferences, testServices } from '$test/fixtures'
import Settings from './Settings.svelte' import Settings from './Settings.svelte'
describe('Settings CSS language', () => { describe('Settings CSS language', () => {
@@ -20,3 +20,47 @@ describe('Settings CSS language', () => {
expect(view.getByText(/anyone who visits your profile with plspace/i)).toBeInTheDocument() 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 { displayNameOf, profilePath } from '$lib/util/profile'
import { stampDate, isoDate } from '$lib/util/time' import { stampDate, isoDate } from '$lib/util/time'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { accountForStatus } from '$lib/util/heleneposting'
import { useTimelineRefresh } from '$lib/timeline-refresh' import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import MfmContent from '$components/common/MfmContent.svelte' import MfmContent from '$components/common/MfmContent.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
@@ -25,7 +27,7 @@
let { id }: Props = $props() let { id }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session, preferences } = useAppServices()
const timelineRefresh = useTimelineRefresh() const timelineRefresh = useTimelineRefresh()
let status = $state<Status | null>(null) let status = $state<Status | null>(null)
let ancestors = $state<Status[]>([]) let ancestors = $state<Status[]>([])
@@ -33,6 +35,9 @@
let loading = $state(true) let loading = $state(true)
let error = $state<string | null>(null) let error = $state<string | null>(null)
let loadGeneration = 0 let loadGeneration = 0
const statusAuthor = $derived(
status ? accountForStatus(status, preferences.heleneposting) : null,
)
interface ThreadedReply { interface ThreadedReply {
status: Status status: Status
@@ -162,7 +167,12 @@
</p> </p>
{:else if status} {:else if status}
<h1 class="page-title"> <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> </h1>
<p class="page-subtitle"> <p class="page-subtitle">
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time> <time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
@@ -213,13 +223,17 @@
{:else} {:else}
<ul class="comment-list"> <ul class="comment-list">
{#each thread as reply (reply.status.id)} {#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}> <li class="comment" data-depth={reply.depth} data-account={reply.status.account.acct}>
<div class="comment-avatar"> <div class="comment-avatar">
<Avatar account={reply.status.account} /> <Avatar account={author} />
</div> </div>
<div class="comment-body"> <div class="comment-body">
<a class="comment-author" href={profilePath(reply.status.account)}> <a class="comment-author" href={profilePath(reply.status.account)}>
{displayNameOf(reply.status.account)} <EmojiText
text={displayNameOf(author)}
emojis={reply.status.account.emojis}
/>
</a> </a>
<a class="comment-date" href={`#/blog/${reply.status.id}`}> <a class="comment-date" href={`#/blog/${reply.status.id}`}>
<time datetime={isoDate(reply.status.created_at)}> <time datetime={isoDate(reply.status.created_at)}>
@@ -228,7 +242,9 @@
</a> </a>
{#if reply.status.spoiler_text} {#if reply.status.spoiler_text}
<details class="content-warning"> <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 <MfmContent
html={reply.status.content} html={reply.status.content}
emojis={reply.status.emojis} 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 injected into sanitized HTML by lib/util/html.ts. */
.custom-emoji { .custom-emoji {
width: var(--ms-emoji-size);
height: var(--ms-emoji-size); height: var(--ms-emoji-size);
width: auto;
max-width: calc(var(--ms-emoji-size) * 2);
vertical-align: text-bottom; vertical-align: text-bottom;
object-fit: contain; object-fit: contain;
} }
+140
View File
@@ -73,6 +73,10 @@
margin-left: calc(var(--ms-avatar-size) + 8px); margin-left: calc(var(--ms-avatar-size) + 8px);
} }
.rich-text .greentext {
color: var(--ms-greentext);
}
.blog-entry[data-compact='true'] .blog-entry-body { .blog-entry[data-compact='true'] .blog-entry-body {
margin-left: 0; margin-left: 0;
} }
@@ -361,6 +365,142 @@
color: inherit; 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 */ /* --------------------------------------------------------------- comments */
.comment-list { .comment-list {
+20
View File
@@ -131,6 +131,26 @@ textarea {
margin-top: 6px; 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 { .composer-counter {
margin-left: auto; margin-left: auto;
color: var(--ms-muted-fg); color: var(--ms-muted-fg);
+2 -2
View File
@@ -62,8 +62,8 @@
} }
.rich-text .mfm .custom-emoji { .rich-text .mfm .custom-emoji {
/* Misskey's emoji width knows no bounds. */ /* Wide emoji are supported, but never wider than twice their height. */
max-width: unset !important; max-width: calc(var(--emoji-size) * 2);
} }
.rich-text:hover .mfm { .rich-text:hover .mfm {
+2
View File
@@ -103,6 +103,8 @@
--ms-notice-bg: #ffffcc; --ms-notice-bg: #ffffcc;
--ms-notice-border: #e6c200; --ms-notice-border: #e6c200;
--ms-highlight-bg: #ffffcc; --ms-highlight-bg: #ffffcc;
/** 4chan's traditional quote colour, used by opt-in Greentexting. */
--ms-greentext: #789922;
/* -------------------------------------------------------------- layout */ /* -------------------------------------------------------------- layout */
--ms-page-width: 800px; --ms-page-width: 800px;
+13
View File
@@ -6,6 +6,7 @@ import {
type AppServiceOverrides, type AppServiceOverrides,
type SessionService, type SessionService,
type ThemeService, type ThemeService,
type PreferencesService,
} from '$lib/app-services' } from '$lib/app-services'
export function account(overrides: Partial<Account> = {}): Account { 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 * Test services fail fast on any endpoint that was not explicitly faked. This
* turns an accidental network request into a local, descriptive test failure. * turns an accidental network request into a local, descriptive test failure.