mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
greentext
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
@@ -16,6 +16,7 @@
|
|||||||
import { renderDisplayName } from '$lib/util/html'
|
import { renderDisplayName } from '$lib/util/html'
|
||||||
import { routeTo } from '$lib/router.svelte'
|
import { routeTo } from '$lib/router.svelte'
|
||||||
import { quoteReferenceOf, quotesCountOf } from '$lib/util/status'
|
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'
|
||||||
@@ -42,12 +43,12 @@
|
|||||||
|
|
||||||
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}`)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_SERVICES } from '$lib/app-services'
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
import { account, session, 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]) {
|
||||||
@@ -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>— 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', () => {
|
describe('BlogEntry quote posts', () => {
|
||||||
it('renders a Pleroma quote and links to quote composition and the quote list', () => {
|
it('renders a Pleroma quote and links to quote composition and the quote list', () => {
|
||||||
const quoted = status({
|
const quoted = status({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { stampDate } from '$lib/util/time'
|
import { stampDate } from '$lib/util/time'
|
||||||
|
import { accountForStatus } from '$lib/util/heleneposting'
|
||||||
import Avatar from '../common/Avatar.svelte'
|
import Avatar from '../common/Avatar.svelte'
|
||||||
import EmojiText from '../common/EmojiText.svelte'
|
import EmojiText from '../common/EmojiText.svelte'
|
||||||
import MfmContent from '../common/MfmContent.svelte'
|
import MfmContent from '../common/MfmContent.svelte'
|
||||||
@@ -18,12 +19,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { quote }: Props = $props()
|
let { quote }: Props = $props()
|
||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session, preferences } = useAppServices()
|
||||||
let loadedStatus = $state<Status | null>(untrack(() => quote.status))
|
let loadedStatus = $state<Status | null>(untrack(() => quote.status))
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let loadError = $state(false)
|
let loadError = $state(false)
|
||||||
let loadGeneration = 0
|
let loadGeneration = 0
|
||||||
const status = $derived((loadedStatus ?? quote.status)?.reblog ?? loadedStatus ?? quote.status)
|
const status = $derived((loadedStatus ?? quote.status)?.reblog ?? loadedStatus ?? quote.status)
|
||||||
|
const author = $derived(status ? accountForStatus(status, preferences.heleneposting) : null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const supplied = quote.status
|
const supplied = quote.status
|
||||||
@@ -69,10 +71,13 @@
|
|||||||
<p class="quote-card-placeholder">Loading quoted entry…</p>
|
<p class="quote-card-placeholder">Loading quoted entry…</p>
|
||||||
{:else if status}
|
{:else if status}
|
||||||
<header class="quote-card-header">
|
<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">
|
<span class="quote-card-byline">
|
||||||
<a class="quote-card-author" href={profilePath(status.account)}>
|
<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>
|
</a>
|
||||||
<span class="quote-card-handle">{fullHandle(status.account, session.host)}</span>
|
<span class="quote-card-handle">{fullHandle(status.account, session.host)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
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,
|
||||||
@@ -35,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>()
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@
|
|||||||
<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 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}
|
||||||
@@ -148,12 +151,12 @@
|
|||||||
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>
|
<strong>
|
||||||
<EmojiText
|
<EmojiText
|
||||||
text={toast.presentation.actor}
|
text={displayNameOf(actor)}
|
||||||
emojis={toast.notification.account.emojis}
|
emojis={toast.notification.account.emojis}
|
||||||
/>
|
/>
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>>first line<br>',
|
||||||
|
'<a class="mention" href="https://example.test/@alice">@alice</a> >mentioned line<br>',
|
||||||
|
'ordinary line</p>',
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
[
|
||||||
|
APP_SERVICES,
|
||||||
|
testServices({
|
||||||
|
preferences: preferences({ greentexting: true }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const green = view.container.querySelectorAll('.greentext')
|
||||||
|
expect(green).toHaveLength(2)
|
||||||
|
expect(green[0]).toHaveTextContent('>first line')
|
||||||
|
expect(green[1]).toHaveTextContent('@alice >mentioned line')
|
||||||
|
expect(view.getByText('ordinary line')).not.toHaveClass('greentext')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not greentext blockquotes, code, lists, or disabled content', () => {
|
||||||
|
const enabled = render(MfmContent, {
|
||||||
|
props: {
|
||||||
|
html: '<blockquote>>quote</blockquote><pre>>code</pre><ul><li>>item</li></ul>',
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
[
|
||||||
|
APP_SERVICES,
|
||||||
|
testServices({
|
||||||
|
preferences: preferences({ greentexting: true }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
expect(enabled.container.querySelector('.greentext')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const disabled = render(MfmContent, {
|
||||||
|
props: { html: '<p>>plain</p>' },
|
||||||
|
context: new Map([[APP_SERVICES, testServices()]]),
|
||||||
|
})
|
||||||
|
expect(disabled.container.querySelector('.greentext')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Private, browser-local feature preferences. */
|
||||||
|
|
||||||
|
const HELENEPOSTING_KEY = 'plspace:heleneposting'
|
||||||
|
const GREENTEXTING_KEY = 'plspace:greentexting'
|
||||||
|
|
||||||
|
class Preferences {
|
||||||
|
heleneposting = $state(false)
|
||||||
|
greentexting = $state(false)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
|
this.heleneposting = localStorage.getItem(HELENEPOSTING_KEY) === 'true'
|
||||||
|
this.greentexting = localStorage.getItem(GREENTEXTING_KEY) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeleneposting(enabled: boolean): void {
|
||||||
|
this.heleneposting = enabled
|
||||||
|
localStorage.setItem(HELENEPOSTING_KEY, String(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
setGreentexting(enabled: boolean): void {
|
||||||
|
this.greentexting = enabled
|
||||||
|
localStorage.setItem(GREENTEXTING_KEY, String(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preferences = new Preferences()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { account, status } from '$test/fixtures'
|
||||||
|
import { accountForStatus, isHelenepost } from './heleneposting'
|
||||||
|
|
||||||
|
describe('Heleneposting detection', () => {
|
||||||
|
it.each([
|
||||||
|
'<p>A note<br>-Helene</p>',
|
||||||
|
'<p>A note</p><p>-- Helene</p>',
|
||||||
|
'<p>A note —<strong>Helene</strong></p>',
|
||||||
|
'<blockquote>A note</blockquote><p>— Helene </p>',
|
||||||
|
'<div><span class="h-card">@sun</span> that is woke —helene</div>',
|
||||||
|
])('recognizes a signed HTML note: %s', (content) => {
|
||||||
|
expect(isHelenepost(content)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'<p>Helene</p>',
|
||||||
|
'<p>---Helene</p>',
|
||||||
|
'<p>—Helene!</p>',
|
||||||
|
'<p>—Helene wrote this</p>',
|
||||||
|
'<p>—helen</p>',
|
||||||
|
])('rejects content that is not an exact final signature: %s', (content) => {
|
||||||
|
expect(isHelenepost(content)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('substitutes presentation fields without changing account identity', () => {
|
||||||
|
const original = account({
|
||||||
|
id: 'actual-id',
|
||||||
|
acct: 'actual@remote.test',
|
||||||
|
display_name: 'Actual Author',
|
||||||
|
avatar: 'https://remote.test/avatar.png',
|
||||||
|
})
|
||||||
|
const presented = accountForStatus(
|
||||||
|
status({ account: original, content: '<p>Hello —Helene</p>' }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(presented).toMatchObject({
|
||||||
|
id: 'actual-id',
|
||||||
|
acct: 'actual@remote.test',
|
||||||
|
display_name: 'Helene',
|
||||||
|
})
|
||||||
|
expect(presented.avatar).toContain('helene')
|
||||||
|
expect(presented.avatar_static).toBe(presented.avatar)
|
||||||
|
expect(original.display_name).toBe('Actual Author')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing while the feature is disabled', () => {
|
||||||
|
const original = account()
|
||||||
|
expect(
|
||||||
|
accountForStatus(status({ account: original, content: '<p>—Helene</p>' }), false),
|
||||||
|
).toBe(original)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import heleneAvatarUrl from '../../assets/helene.png'
|
||||||
|
import type { Account, Notification, Status } from '../api/types'
|
||||||
|
import { toPlainText } from './html'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Helene signature is the final visible text in a note. Work from the
|
||||||
|
* sanitized plain-text projection rather than the API's HTML so closing tags,
|
||||||
|
* nested formatting and encoded em dashes cannot obscure the suffix.
|
||||||
|
*/
|
||||||
|
export function isHelenepost(content: string | null | undefined): boolean {
|
||||||
|
const text = toPlainText(content)
|
||||||
|
return /(?:(?<!-)--?|—)\s*Helene$/i.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve the real account identity and links while changing its presentation. */
|
||||||
|
export function accountForStatus(status: Status, enabled: boolean): Account {
|
||||||
|
if (!enabled || !isHelenepost(status.content)) return status.account
|
||||||
|
return {
|
||||||
|
...status.account,
|
||||||
|
display_name: 'Helene',
|
||||||
|
avatar: heleneAvatarUrl,
|
||||||
|
avatar_static: heleneAvatarUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification's status is not always authored by its actor (a favourite
|
||||||
|
* notification includes the recipient's status). Only transform the actor
|
||||||
|
* when the attached note confirms that they authored it.
|
||||||
|
*/
|
||||||
|
export function accountForNotification(notification: Notification, enabled: boolean): Account {
|
||||||
|
if (
|
||||||
|
!notification.status ||
|
||||||
|
notification.status.account.id !== notification.account.id
|
||||||
|
) {
|
||||||
|
return notification.account
|
||||||
|
}
|
||||||
|
return accountForStatus(notification.status, enabled)
|
||||||
|
}
|
||||||
+161
-1
@@ -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('>')) 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
} 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 { escapeHtml, 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'
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
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[]>([])
|
||||||
@@ -330,11 +331,12 @@
|
|||||||
<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)}>
|
||||||
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
|
<EmojiText text={displayNameOf(author)} emojis={entry.account.emojis} />
|
||||||
</a>
|
</a>
|
||||||
<MfmContent
|
<MfmContent
|
||||||
html={entry.spoiler_text ? `<p>${escapeHtml(entry.spoiler_text)}</p>` : entry.content}
|
html={entry.spoiler_text ? `<p>${escapeHtml(entry.spoiler_text)}</p>` : entry.content}
|
||||||
@@ -381,10 +383,11 @@
|
|||||||
<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)}>
|
<a href={profilePath(entry.account)}>
|
||||||
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
|
<EmojiText text={displayNameOf(author)} emojis={entry.account.emojis} />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
|
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
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 { 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'
|
||||||
@@ -26,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
|
||||||
@@ -212,17 +213,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each notifications.items as item (item.id)}
|
{#each notifications.items as item (item.id)}
|
||||||
{@const reaction = emojiForNotification(item)}
|
{@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)}>
|
<a href={profilePath(item.account)}>
|
||||||
<EmojiText text={displayNameOf(item.account)} emojis={item.account.emojis} />
|
<EmojiText text={displayNameOf(actor)} emojis={item.account.emojis} />
|
||||||
</a>
|
</a>
|
||||||
</strong>
|
</strong>
|
||||||
{VERB[item.type] ?? item.type}
|
{VERB[item.type] ?? item.type}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
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)
|
||||||
@@ -66,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 you’ve activated'],
|
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||||
],
|
],
|
||||||
@@ -126,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>—Helene</code>, show its author as Helene with the Heleneposting avatar.
|
||||||
|
This is a private display preference stored only in this browser; it does not modify
|
||||||
|
anyone's posts or profile.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.greentexting}
|
||||||
|
onchange={(event) => preferences.setGreentexting(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>Enable Greentexting</span>
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">
|
||||||
|
Show lines beginning with <code>></code> in 4chan-style green. Leading mentions
|
||||||
|
are ignored when detecting the beginning of a line. This affects display only and is
|
||||||
|
stored in this browser.
|
||||||
|
</p>
|
||||||
|
</Module>
|
||||||
|
|
||||||
<Module title="Pick a layout" variant="band">
|
<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)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
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'
|
||||||
@@ -26,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[]>([])
|
||||||
@@ -34,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
|
||||||
@@ -164,7 +168,10 @@
|
|||||||
{:else if status}
|
{:else if status}
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
<a href={profilePath(status.account)}>
|
<a href={profilePath(status.account)}>
|
||||||
<EmojiText text={displayNameOf(status.account)} emojis={status.account.emojis} />
|
<EmojiText
|
||||||
|
text={displayNameOf(statusAuthor ?? status.account)}
|
||||||
|
emojis={status.account.emojis}
|
||||||
|
/>
|
||||||
</a>'s Blog
|
</a>'s Blog
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
@@ -216,14 +223,15 @@
|
|||||||
{: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)}>
|
||||||
<EmojiText
|
<EmojiText
|
||||||
text={displayNameOf(reply.status.account)}
|
text={displayNameOf(author)}
|
||||||
emojis={reply.status.account.emojis}
|
emojis={reply.status.account.emojis}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user