greentext

This commit is contained in:
Moon.eth
2026-07-30 20:22:52 +09:00
parent 657d517503
commit f735de5185
20 changed files with 530 additions and 26 deletions
+3 -2
View File
@@ -16,6 +16,7 @@
import { renderDisplayName } from '$lib/util/html'
import { routeTo } from '$lib/router.svelte'
import { quoteReferenceOf, quotesCountOf } from '$lib/util/status'
import { accountForStatus } from '$lib/util/heleneposting'
import { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte'
@@ -42,12 +43,12 @@
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
const { endpoints, session } = useAppServices()
const { endpoints, session, preferences } = useAppServices()
/** The status actually being displayed; a boost renders its target. */
const entry = $derived(status.reblog ?? status)
const booster = $derived(status.reblog ? status.account : null)
const author = $derived(entry.account)
const author = $derived(accountForStatus(entry, preferences.heleneposting))
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
const handle = $derived(fullHandle(author, session.host))
const permalink = $derived(`#/blog/${entry.id}`)
+35 -1
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import { account, session, status, testServices } from '$test/fixtures'
import { account, preferences, session, status, testServices } from '$test/fixtures'
import BlogEntry from './BlogEntry.svelte'
function renderEntry(overrides: Parameters<typeof status>[0]) {
@@ -67,6 +67,40 @@ 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({
+8 -3
View File
@@ -6,6 +6,7 @@
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'
@@ -18,12 +19,13 @@
}
let { quote }: Props = $props()
const { endpoints, session } = useAppServices()
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
@@ -69,10 +71,13 @@
<p class="quote-card-placeholder">Loading quoted entry&hellip;</p>
{:else if status}
<header class="quote-card-header">
<Avatar account={status.account} plain class="quote-card-avatar" />
<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(status.account)} emojis={status.account.emojis} />
<EmojiText
text={displayNameOf(author ?? status.account)}
emojis={status.account.emojis}
/>
</a>
<span class="quote-card-handle">{fullHandle(status.account, session.host)}</span>
</span>
@@ -7,6 +7,8 @@
import type { ApiClient } from '$lib/api/client'
import type { Notification } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { accountForNotification } from '$lib/util/heleneposting'
import { displayNameOf } from '$lib/util/profile'
import {
NOTIFICATION_DISMISS_AFTER_MS,
NOTIFICATION_POLL_INTERVAL_MS,
@@ -35,7 +37,7 @@
maxToasts = 4,
}: Props = $props()
const { endpoints, session } = useAppServices()
const { endpoints, session, preferences } = useAppServices()
const tracker = new NotificationTracker()
const dismissTimers = new Map<string, number>()
@@ -141,6 +143,7 @@
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
{#each toasts as toast (toast.notification.id)}
{@const reaction = emojiForNotification(toast.notification)}
{@const actor = accountForNotification(toast.notification, preferences.heleneposting)}
<a
class="notification-toast"
href={toast.presentation.href}
@@ -148,12 +151,12 @@
data-account={toast.notification.account.acct}
onclick={() => dismiss(toast.notification.id)}
>
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
<Avatar account={actor} plain class="notification-toast-avatar" />
<span class="notification-toast-body">
<span class="notification-toast-message">
<strong>
<EmojiText
text={toast.presentation.actor}
text={displayNameOf(actor)}
emojis={toast.notification.account.emojis}
/>
</strong>
+11 -1
View File
@@ -6,6 +6,7 @@
* it intentionally does not parse raw `$[...]` source syntax.
*/
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { renderMfmHtml } from '$lib/util/mfm'
interface Props {
@@ -33,8 +34,17 @@
scale = false,
}: Props = $props()
const { preferences } = useAppServices()
const rendered = $derived(
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }),
renderMfmHtml(html, {
emojis,
mentions,
tags,
inline,
pause,
scale,
greentext: preferences.greentexting,
}),
)
</script>
+51
View File
@@ -1,5 +1,7 @@
import { render } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import { preferences, testServices } from '$test/fixtures'
import MfmContent from './MfmContent.svelte'
function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
@@ -70,4 +72,53 @@ describe('MfmContent', () => {
'https://example.test/emoji/party.png',
)
})
it('greentexts visual lines, including prose after a leading mention', () => {
const view = render(MfmContent, {
props: {
html: [
'<p>&gt;first line<br>',
'<a class="mention" href="https://example.test/@alice">@alice</a> &gt;mentioned line<br>',
'ordinary line</p>',
].join(''),
},
context: new Map([
[
APP_SERVICES,
testServices({
preferences: preferences({ greentexting: true }),
}),
],
]),
})
const green = view.container.querySelectorAll('.greentext')
expect(green).toHaveLength(2)
expect(green[0]).toHaveTextContent('>first line')
expect(green[1]).toHaveTextContent('@alice >mentioned line')
expect(view.getByText('ordinary line')).not.toHaveClass('greentext')
})
it('does not greentext blockquotes, code, lists, or disabled content', () => {
const enabled = render(MfmContent, {
props: {
html: '<blockquote>&gt;quote</blockquote><pre>&gt;code</pre><ul><li>&gt;item</li></ul>',
},
context: new Map([
[
APP_SERVICES,
testServices({
preferences: preferences({ greentexting: true }),
}),
],
]),
})
expect(enabled.container.querySelector('.greentext')).not.toBeInTheDocument()
const disabled = render(MfmContent, {
props: { html: '<p>&gt;plain</p>' },
context: new Map([[APP_SERVICES, testServices()]]),
})
expect(disabled.container.querySelector('.greentext')).not.toBeInTheDocument()
})
})