mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
custom css works now
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiClient } from './client'
|
||||
import { updateProfileFields } from './endpoints'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('updateProfileFields', () => {
|
||||
it('uses the Pleroma/Mastodon indexed multipart representation', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.method).toBe('PATCH')
|
||||
expect(init?.headers).toBeInstanceOf(Headers)
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer token')
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const form = init?.body as FormData
|
||||
expect([...form.entries()]).toEqual([
|
||||
['fields_attributes[0][name]', 'Website'],
|
||||
['fields_attributes[0][value]', 'https://example.test'],
|
||||
['fields_attributes[1][name]', 'plspace:css1'],
|
||||
['fields_attributes[1][value]', 'body { color: pink; }'],
|
||||
])
|
||||
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await updateProfileFields(new ApiClient('example.test', 'token'), [
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'plspace:css1', value: 'body { color: pink; }' },
|
||||
])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/accounts/update_credentials',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,27 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
|
||||
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the signed-in account's profile fields while leaving every other
|
||||
* credential untouched. Mastodon and Pleroma both accept this indexed
|
||||
* multipart shape; Pleroma-FE uses the same representation.
|
||||
*/
|
||||
export async function updateProfileFields(
|
||||
api: ApiClient,
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
): Promise<CredentialAccount> {
|
||||
const form = new FormData()
|
||||
fields.forEach((field, index) => {
|
||||
form.append(`fields_attributes[${index}][name]`, field.name)
|
||||
form.append(`fields_attributes[${index}][value]`, field.value)
|
||||
})
|
||||
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
||||
method: 'PATCH',
|
||||
form,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
||||
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
+21
-2
@@ -266,11 +266,30 @@ export interface InstanceInfo {
|
||||
max_characters?: number
|
||||
max_media_attachments?: number
|
||||
}
|
||||
accounts?: {
|
||||
max_profile_fields?: number
|
||||
/** Pleroma v2 names. */
|
||||
profile_field_name_limit?: number
|
||||
profile_field_value_limit?: number
|
||||
/** Mastodon-compatible aliases used by some implementations. */
|
||||
max_profile_field_name_length?: number
|
||||
max_profile_field_value_length?: number
|
||||
}
|
||||
}
|
||||
registrations?: boolean | { enabled?: boolean }
|
||||
contact_account?: Account | null
|
||||
/** Pleroma reports its "real" upstream here. */
|
||||
pleroma?: unknown
|
||||
/** Pleroma reports its "real" upstream and field limits here. */
|
||||
pleroma?: {
|
||||
metadata?: {
|
||||
fields_limits?: {
|
||||
max_fields?: number
|
||||
name_length?: number
|
||||
value_length?: number
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
|
||||
+226
-10
@@ -16,6 +16,8 @@
|
||||
* run script.
|
||||
*/
|
||||
|
||||
import type { InstanceInfo } from '../api/types'
|
||||
|
||||
const VIEWER_STYLE_ID = 'user-stylesheet'
|
||||
const PROFILE_STYLE_ID = 'profile-stylesheet'
|
||||
const STORAGE_KEY = 'plspace:viewer-css'
|
||||
@@ -26,6 +28,228 @@ export const PROFILE_SCOPE = '.profile-page'
|
||||
/** Field names checked, in order, for a profile's published stylesheet. */
|
||||
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
|
||||
|
||||
const NAMESPACED_CSS_FIELD_PATTERN = /^plspace:css(\d+)$/i
|
||||
const LEGACY_CSS_FIELD_PATTERN = /^(css|style|layout|stylesheet)(?:[\s_-]+(\d+))?$/i
|
||||
const ENCODED_CSS_PREFIX = 'plspace-css-b64:'
|
||||
|
||||
export interface ProfileFieldLimits {
|
||||
maxFields: number
|
||||
nameLength: number
|
||||
valueLength: number
|
||||
}
|
||||
|
||||
/** Normalize Mastodon v2 and Pleroma extension shapes, with safe old-server defaults. */
|
||||
export function profileFieldLimits(instance: InstanceInfo | null): ProfileFieldLimits {
|
||||
const accounts = instance?.configuration?.accounts
|
||||
const pleroma = instance?.pleroma?.metadata?.fields_limits
|
||||
const isPleroma = Boolean(instance?.pleroma)
|
||||
return {
|
||||
maxFields: accounts?.max_profile_fields ?? pleroma?.max_fields ?? (isPleroma ? 10 : 4),
|
||||
nameLength:
|
||||
accounts?.profile_field_name_limit ??
|
||||
accounts?.max_profile_field_name_length ??
|
||||
pleroma?.name_length ??
|
||||
255,
|
||||
valueLength:
|
||||
accounts?.profile_field_value_limit ??
|
||||
accounts?.max_profile_field_value_length ??
|
||||
pleroma?.value_length ??
|
||||
(isPleroma ? 2048 : 255),
|
||||
}
|
||||
}
|
||||
|
||||
function fieldText(field: { value: string }): string {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
for (const br of Array.from(container.querySelectorAll('br'))) {
|
||||
br.replaceWith(document.createTextNode('\n'))
|
||||
}
|
||||
for (const block of Array.from(container.querySelectorAll('p, div, li'))) {
|
||||
if (block.nextSibling) block.append(document.createTextNode('\n'))
|
||||
}
|
||||
return container.textContent ?? ''
|
||||
}
|
||||
|
||||
function cssField(
|
||||
field: { name: string },
|
||||
): { namespace: 'plspace' | 'legacy'; base: string; part: number } | null {
|
||||
const name = field.name.trim()
|
||||
const namespaced = NAMESPACED_CSS_FIELD_PATTERN.exec(name)
|
||||
if (namespaced) {
|
||||
return { namespace: 'plspace', base: 'css', part: Number(namespaced[1]) }
|
||||
}
|
||||
const legacy = LEGACY_CSS_FIELD_PATTERN.exec(name)
|
||||
if (!legacy) return null
|
||||
return {
|
||||
namespace: 'legacy',
|
||||
base: legacy[1].toLowerCase(),
|
||||
part: legacy[2] ? Number(legacy[2]) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reassemble a single CSS field or the ordered chunks written by plspace. */
|
||||
export function publishedCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string {
|
||||
if (!fields?.length) return ''
|
||||
const namespaced = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter((entry) => entry.parsed?.namespace === 'plspace')
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (namespaced.length > 0) {
|
||||
const values = namespaced.map(({ field }) => fieldText(field))
|
||||
if (values.every((value) => value.startsWith(ENCODED_CSS_PREFIX))) {
|
||||
try {
|
||||
return decodeCss(
|
||||
values.map((value) => value.slice(ENCODED_CSS_PREFIX.length)).join(''),
|
||||
).trim()
|
||||
} catch {
|
||||
// Keep profiles readable if a field was edited or truncated elsewhere.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return values.join('').trim()
|
||||
}
|
||||
|
||||
for (const preferred of CSS_FIELD_NAMES) {
|
||||
const chunks = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.parsed?.namespace === 'legacy' && entry.parsed.base === preferred,
|
||||
)
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (chunks.length > 0) return chunks.map(({ field }) => fieldText(field)).join('').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers normalize bare LF line endings to CRLF when serializing FormData,
|
||||
* and Pleroma validates the decoded multipart value. Budget two characters for
|
||||
* each newline so a nominal 2,048-character chunk cannot arrive over the limit.
|
||||
*
|
||||
* Split on rule boundaries when possible while otherwise retaining the source.
|
||||
* Chunks are concatenated without a separator, including when a very long
|
||||
* quoted data URI has to be split in its middle.
|
||||
*/
|
||||
export function splitPublishedCss(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
const safeLimit = Math.max(1, Math.floor(limit))
|
||||
const chunks: string[] = []
|
||||
let rest = source
|
||||
|
||||
while (multipartFieldLength(rest) > safeLimit) {
|
||||
let windowEnd = 0
|
||||
let encodedLength = 0
|
||||
while (windowEnd < rest.length) {
|
||||
const width = rest[windowEnd] === '\n' ? 2 : 1
|
||||
if (encodedLength + width > safeLimit) break
|
||||
encodedLength += width
|
||||
windowEnd += 1
|
||||
}
|
||||
// A one-character limit can still make progress on a newline.
|
||||
if (windowEnd === 0) windowEnd = 1
|
||||
|
||||
const window = rest.slice(0, windowEnd)
|
||||
let cut = window.lastIndexOf('}')
|
||||
if (cut >= 0) cut += 1
|
||||
else {
|
||||
cut = window.lastIndexOf('\n')
|
||||
if (cut <= 0) cut = windowEnd
|
||||
}
|
||||
chunks.push(rest.slice(0, cut))
|
||||
rest = rest.slice(cut)
|
||||
}
|
||||
if (rest) chunks.push(rest)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Length after the newline normalization used for multipart form fields. */
|
||||
export function multipartFieldLength(value: string): number {
|
||||
let length = value.length
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '\n' && value[index - 1] !== '\r') length += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
function encodeCss(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
let binary = ''
|
||||
const windowSize = 0x8000
|
||||
for (let index = 0; index < bytes.length; index += windowSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + windowSize))
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function decodeCss(value: string): string {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode published CSS before placing it in profile fields.
|
||||
*
|
||||
* Pleroma linkifies raw profile-field values before enforcing its configured
|
||||
* value limit. CSS commonly contains URLs and hashtag-shaped tokens such as
|
||||
* `#fff`, so a raw value under the advertised limit can expand into HTML over
|
||||
* that limit. Base64url has no linkifiable syntax, making its server-side
|
||||
* length identical to the length measured here.
|
||||
*/
|
||||
export function publishedCssFieldValues(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
|
||||
const capacity = Math.floor(limit) - ENCODED_CSS_PREFIX.length
|
||||
if (capacity < 1) {
|
||||
throw new Error('This server’s profile field-value limit is too small for encoded CSS.')
|
||||
}
|
||||
|
||||
const encoded = encodeCss(source)
|
||||
const values: string[] = []
|
||||
for (let index = 0; index < encoded.length; index += capacity) {
|
||||
values.push(ENCODED_CSS_PREFIX + encoded.slice(index, index + capacity))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve normal profile fields and replace all prior CSS chunks atomically.
|
||||
* Legacy CSS fields are migrated to the unambiguous `plspace:css1` namespace.
|
||||
*/
|
||||
export function replacePublishedCssFields(
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
css: string,
|
||||
limits: ProfileFieldLimits,
|
||||
): Array<{ name: string; value: string }> {
|
||||
const existingCss = fields.map((field) => ({ field, parsed: cssField(field) }))
|
||||
const ordinary = existingCss
|
||||
.filter((entry) => !entry.parsed)
|
||||
.map(({ field }) => ({ name: field.name, value: field.value }))
|
||||
const chunks = publishedCssFieldValues(css, limits.valueLength)
|
||||
const cssFields = chunks.map((value, index) => ({
|
||||
name: `plspace:css${index + 1}`,
|
||||
value,
|
||||
}))
|
||||
|
||||
if (ordinary.length + cssFields.length > limits.maxFields) {
|
||||
const available = Math.max(0, limits.maxFields - ordinary.length)
|
||||
throw new Error(
|
||||
`This server has room for ${available} CSS field${available === 1 ? '' : 's'}, but this stylesheet needs ${cssFields.length}. Shorten it or remove another profile field.`,
|
||||
)
|
||||
}
|
||||
if (cssFields.some((field) => field.name.length > limits.nameLength)) {
|
||||
throw new Error('This server’s profile field-name limit is too small for chunked CSS.')
|
||||
}
|
||||
return [...ordinary, ...cssFields]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or create) a style element, always moving it to the end of `<head>`.
|
||||
*
|
||||
@@ -194,14 +418,6 @@ export const theme = new Theme()
|
||||
export function profileCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string | null {
|
||||
if (!fields?.length) return null
|
||||
for (const field of fields) {
|
||||
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
|
||||
// Field values arrive as HTML; take the text and undo entity escaping.
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
const text = (container.textContent ?? '').trim()
|
||||
if (text.includes('{')) return text
|
||||
}
|
||||
return null
|
||||
const css = publishedCssFromFields(fields)
|
||||
return css.includes('{') ? css : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
profileCssFromFields,
|
||||
profileFieldLimits,
|
||||
multipartFieldLength,
|
||||
publishedCssFieldValues,
|
||||
publishedCssFromFields,
|
||||
replacePublishedCssFields,
|
||||
splitPublishedCss,
|
||||
} from './theme.svelte'
|
||||
|
||||
describe('published profile CSS fields', () => {
|
||||
it('reads ordinary and chunked CSS fields in numeric order', () => {
|
||||
const fields = [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example</a>' },
|
||||
{ name: 'plspace:css3', value: '<p>.three { color: blue; }</p>' },
|
||||
{ name: 'plspace:css1', value: '<p>.one { color: red; }</p>' },
|
||||
{ name: 'plspace:css2', value: '<p>.two { color: green; }</p>' },
|
||||
]
|
||||
|
||||
expect(publishedCssFromFields(fields)).toBe(
|
||||
'.one { color: red; }.two { color: green; }.three { color: blue; }',
|
||||
)
|
||||
expect(profileCssFromFields(fields)).toContain('.three { color: blue; }')
|
||||
})
|
||||
|
||||
it('splits long stylesheets on complete rule boundaries', () => {
|
||||
const css = '.one { color: red; }\n.two { color: green; }\n.three { color: blue; }'
|
||||
const chunks = splitPublishedCss(css, 42)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 42)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('budgets for FormData expanding line feeds to CRLF', () => {
|
||||
const css = Array.from({ length: 20 }, (_, index) => `.r${index} {\n color: pink;\n}`).join(
|
||||
'\n',
|
||||
)
|
||||
const chunks = splitPublishedCss(css, 80)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 80)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('stores CSS as non-linkifiable chunks and decodes Unicode exactly', () => {
|
||||
const css =
|
||||
'.profile { color: #fff; background: url("https://example.test/image.png"); }\n/* 日本語 🌸 */'
|
||||
const values = publishedCssFieldValues(css, 48)
|
||||
|
||||
expect(values.length).toBeGreaterThan(1)
|
||||
expect(values.every((value) => value.length <= 48)).toBe(true)
|
||||
expect(values.every((value) => /^plspace-css-b64:[A-Za-z0-9_-]+$/.test(value))).toBe(true)
|
||||
expect(
|
||||
publishedCssFromFields(
|
||||
values.map((value, index) => ({ name: `plspace:css${index + 1}`, value })),
|
||||
),
|
||||
).toBe(css)
|
||||
})
|
||||
|
||||
it('replaces old CSS chunks while preserving unrelated profile fields', () => {
|
||||
const result = replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'style', value: 'old' },
|
||||
{ name: 'style 2', value: 'old continuation' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
],
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
{ maxFields: 6, nameLength: 255, valueLength: 55 },
|
||||
)
|
||||
|
||||
expect(result.slice(0, 2)).toEqual([
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
])
|
||||
expect(result.slice(2).map((field) => field.name)).toEqual([
|
||||
'plspace:css1',
|
||||
'plspace:css2',
|
||||
])
|
||||
expect(publishedCssFromFields(result.slice(2))).toBe(
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes only CSS fields when publishing an empty value', () => {
|
||||
expect(
|
||||
replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'css', value: 'body {}' },
|
||||
{ name: 'plspace:css2', value: '.more {}' },
|
||||
{ name: 'Location', value: 'Tokyo' },
|
||||
],
|
||||
'',
|
||||
{ maxFields: 4, nameLength: 255, valueLength: 255 },
|
||||
),
|
||||
).toEqual([{ name: 'Location', value: 'Tokyo' }])
|
||||
})
|
||||
|
||||
it('rejects a stylesheet that exceeds the server field count', () => {
|
||||
expect(() =>
|
||||
replacePublishedCssFields(
|
||||
[{ name: 'Website', value: 'https://example.test' }],
|
||||
'.a{color:red}.b{color:blue}.c{color:green}',
|
||||
{ maxFields: 2, nameLength: 255, valueLength: 30 },
|
||||
),
|
||||
).toThrow(/room for 1 CSS field/)
|
||||
})
|
||||
|
||||
it('normalizes both Mastodon and Pleroma field-limit shapes', () => {
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Mastodon',
|
||||
version: '4.5.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 4,
|
||||
max_profile_field_name_length: 255,
|
||||
max_profile_field_value_length: 500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 4, nameLength: 255, valueLength: 500 })
|
||||
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
profile_field_name_limit: 512,
|
||||
profile_field_value_limit: 2048,
|
||||
},
|
||||
},
|
||||
pleroma: {
|
||||
metadata: {
|
||||
fields_limits: { max_fields: 9, name_length: 500, value_length: 2000 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 10, nameLength: 512, valueLength: 2048 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { account } from '$test/fixtures'
|
||||
import { buildProfileView } from './profile'
|
||||
|
||||
describe('buildProfileView', () => {
|
||||
it('hides namespaced plspace storage fields from visible profile details', () => {
|
||||
const profile = buildProfileView(
|
||||
account({
|
||||
fields: [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example.test</a>' },
|
||||
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
|
||||
{ name: ' PLSPACE:future-setting ', value: 'internal' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(profile.details).toHaveLength(1)
|
||||
expect(profile.details[0].name).toBe('Website')
|
||||
expect(profile.details[0].value).toContain('example.test')
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,10 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
|
||||
for (const field of account.fields ?? []) {
|
||||
const key = field.name.trim().toLowerCase()
|
||||
// `plspace:` fields are application storage, not user-facing profile
|
||||
// details. Keep them on the Account for features such as published CSS,
|
||||
// but never turn them into visible name/value rows.
|
||||
if (key.startsWith('plspace:')) continue
|
||||
if (CHROME_FIELDS.has(key)) continue
|
||||
|
||||
const value = renderHtml(field.value, { emojis: account.emojis })
|
||||
|
||||
Reference in New Issue
Block a user