mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
greentext
This commit is contained in:
@@ -12,6 +12,7 @@ import * as endpointImplementations from './api/endpoints'
|
||||
import { router as defaultRouter, type RouteMatch } from './router.svelte'
|
||||
import { session as defaultSession } from './stores/session.svelte'
|
||||
import { theme as defaultTheme } from './stores/theme.svelte'
|
||||
import { preferences as defaultPreferences } from './stores/preferences.svelte'
|
||||
|
||||
export interface SessionService {
|
||||
host: string
|
||||
@@ -45,10 +46,18 @@ export interface ThemeService {
|
||||
clearProfileCss(): void
|
||||
}
|
||||
|
||||
export interface PreferencesService {
|
||||
heleneposting: boolean
|
||||
greentexting: boolean
|
||||
setHeleneposting(enabled: boolean): void
|
||||
setGreentexting(enabled: boolean): void
|
||||
}
|
||||
|
||||
export interface AppServices {
|
||||
session: SessionService
|
||||
router: RouterService
|
||||
theme: ThemeService
|
||||
preferences: PreferencesService
|
||||
endpoints: typeof endpointImplementations
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ export const defaultAppServices: AppServices = {
|
||||
session: defaultSession,
|
||||
router: defaultRouter,
|
||||
theme: defaultTheme,
|
||||
preferences: defaultPreferences,
|
||||
endpoints: endpointImplementations,
|
||||
}
|
||||
|
||||
@@ -70,6 +80,7 @@ export interface AppServiceOverrides {
|
||||
session?: SessionService
|
||||
router?: RouterService
|
||||
theme?: ThemeService
|
||||
preferences?: PreferencesService
|
||||
endpoints?: Partial<typeof endpointImplementations>
|
||||
}
|
||||
|
||||
@@ -79,6 +90,7 @@ export function createAppServices(overrides: AppServiceOverrides = {}): AppServi
|
||||
session: overrides.session ?? defaultAppServices.session,
|
||||
router: overrides.router ?? defaultAppServices.router,
|
||||
theme: overrides.theme ?? defaultAppServices.theme,
|
||||
preferences: overrides.preferences ?? defaultAppServices.preferences,
|
||||
endpoints: { ...defaultAppServices.endpoints, ...overrides.endpoints },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/** Scale effects against the surrounding custom-emoji size. */
|
||||
scale?: boolean
|
||||
/** Colour visual lines whose prose starts with `>`, following Pleroma-FE. */
|
||||
greentext?: boolean
|
||||
}
|
||||
|
||||
const LOOPING_OPERATORS = new Set([
|
||||
@@ -27,6 +29,163 @@ const LOOPING_OPERATORS = new Set([
|
||||
'rainbow',
|
||||
])
|
||||
|
||||
const EMPTY_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
])
|
||||
|
||||
const BLOCK_ELEMENTS = new Set([
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'blockquote',
|
||||
'details',
|
||||
'dialog',
|
||||
'dd',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'fieldset',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'footer',
|
||||
'form',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'li',
|
||||
'main',
|
||||
'nav',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'section',
|
||||
'table',
|
||||
'ul',
|
||||
])
|
||||
|
||||
const VISUAL_LINE_ELEMENTS = new Set([...BLOCK_ELEMENTS, 'br'])
|
||||
const NON_EMPTY_LINE_ELEMENTS = new Set(
|
||||
[...VISUAL_LINE_ELEMENTS].filter((element) => !EMPTY_ELEMENTS.has(element)),
|
||||
)
|
||||
const RECOGNIZED_LINE_ELEMENTS = new Set([
|
||||
...NON_EMPTY_LINE_ELEMENTS,
|
||||
...EMPTY_ELEMENTS,
|
||||
])
|
||||
|
||||
interface HtmlLine {
|
||||
level: string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
function tagName(tag: string): string | null {
|
||||
const match = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/is.exec(tag)
|
||||
return (match?.[1] ?? match?.[2] ?? null)?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pleroma-FE-compatible visual-line tokenizer. Inline markup remains in its
|
||||
* line, while block elements, `<br>`, and literal newlines form boundaries.
|
||||
*/
|
||||
function htmlLines(html: string): Array<string | HtmlLine> {
|
||||
const output: Array<string | HtmlLine> = []
|
||||
const level: string[] = []
|
||||
let text = ''
|
||||
let tag: string | null = null
|
||||
|
||||
const flush = (): void => {
|
||||
output.push(text.trim() ? { level: [...level], text } : text)
|
||||
text = ''
|
||||
}
|
||||
|
||||
for (const character of html) {
|
||||
if (character === '<' && tag === null) {
|
||||
tag = character
|
||||
} else if (character !== '>' && tag !== null) {
|
||||
tag += character
|
||||
} else if (character === '>' && tag !== null) {
|
||||
tag += character
|
||||
const complete = tag
|
||||
tag = null
|
||||
const name = tagName(complete)
|
||||
if (!name || !RECOGNIZED_LINE_ELEMENTS.has(name)) {
|
||||
text += complete
|
||||
} else if (name === 'br') {
|
||||
flush()
|
||||
output.push(complete)
|
||||
} else if (NON_EMPTY_LINE_ELEMENTS.has(name)) {
|
||||
if (complete[1] === '/') {
|
||||
if (level[0] === name) {
|
||||
flush()
|
||||
output.push(complete)
|
||||
level.shift()
|
||||
} else {
|
||||
text += complete
|
||||
}
|
||||
} else if (complete[complete.length - 2] === '/') {
|
||||
flush()
|
||||
output.push(complete)
|
||||
} else {
|
||||
flush()
|
||||
output.push(complete)
|
||||
level.unshift(name)
|
||||
}
|
||||
} else {
|
||||
text += complete
|
||||
}
|
||||
} else if (character === '\n') {
|
||||
flush()
|
||||
output.push(character)
|
||||
} else {
|
||||
text += character
|
||||
}
|
||||
}
|
||||
|
||||
if (tag) text += tag
|
||||
flush()
|
||||
return output
|
||||
}
|
||||
|
||||
/** Add trusted presentation spans after sanitization, never before it. */
|
||||
export function enhanceGreentextHtml(html: string, enabled: boolean): string {
|
||||
if (!enabled || !html.includes('>')) return html
|
||||
|
||||
return htmlLines(html)
|
||||
.map((line) => {
|
||||
if (typeof line === 'string') return line
|
||||
if (!line.level.every((element) => element === 'p' || element === 'div')) {
|
||||
return line.text
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = line.text
|
||||
const prose = (container.textContent ?? '').replace(/@\w+/gi, '').trim()
|
||||
return prose.startsWith('>')
|
||||
? `<span class="greentext">${line.text}</span>`
|
||||
: line.text
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function numberAttribute(element: Element, name: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
|
||||
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
|
||||
@@ -173,5 +332,6 @@ export function renderMfmHtml(
|
||||
source: string | null | undefined,
|
||||
options: MfmRenderOptions = {},
|
||||
): string {
|
||||
return enhanceMfmHtml(renderHtml(source, options), options)
|
||||
const html = enhanceGreentextHtml(renderHtml(source, options), options.greentext === true)
|
||||
return enhanceMfmHtml(html, options)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user