mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
top 8 feature
This commit is contained in:
@@ -350,6 +350,8 @@ export interface InstanceInfo {
|
||||
}
|
||||
accounts?: {
|
||||
max_profile_fields?: number
|
||||
/** Mastodon 4.6+ bio limit. */
|
||||
max_note_length?: number
|
||||
/** Pleroma v2 names. */
|
||||
profile_field_name_limit?: number
|
||||
profile_field_value_limit?: number
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { Account } from '../api/types'
|
||||
import { renderHtml, toPlainText } from './html'
|
||||
import { topEightFromHtml } from './top-eight'
|
||||
|
||||
/** The interest rows a MySpace profile shipped with, in their original order. */
|
||||
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
|
||||
@@ -70,6 +71,8 @@ export interface ProfileView {
|
||||
about: string
|
||||
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
|
||||
wantsToMeet: string
|
||||
/** Fully-qualified handles declared in the portable bio section. */
|
||||
topEightHandles: string[]
|
||||
interests: InterestEntry[]
|
||||
details: ProfileField[]
|
||||
}
|
||||
@@ -124,7 +127,9 @@ export function fallbackMood(seed: string): string {
|
||||
}
|
||||
|
||||
export function buildProfileView(account: Account): ProfileView {
|
||||
const noteHtml = renderHtml(account.note, { emojis: account.emojis })
|
||||
const renderedNote = renderHtml(account.note, { emojis: account.emojis })
|
||||
const topEight = topEightFromHtml(renderedNote)
|
||||
const noteHtml = topEight.html
|
||||
const { about, meet } = splitBio(noteHtml)
|
||||
|
||||
const interests: InterestEntry[] = []
|
||||
@@ -155,7 +160,7 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row))
|
||||
|
||||
const headlineField = findField(account, ['headline', 'status'])
|
||||
const headline = headlineField ?? firstSentence(toPlainText(account.note)) ?? '"..."'
|
||||
const headline = headlineField ?? firstSentence(toPlainText(noteHtml)) ?? '"..."'
|
||||
|
||||
const ageField = findField(account, ['age'])
|
||||
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
|
||||
@@ -169,6 +174,7 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
age: Number.isFinite(parsedAge) ? parsedAge : null,
|
||||
about,
|
||||
wantsToMeet: meet,
|
||||
topEightHandles: topEight.handles,
|
||||
interests,
|
||||
details,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseTopEightText, topEightFromHtml, withTopEight } from './top-eight'
|
||||
|
||||
describe('Top 8 profile bio format', () => {
|
||||
it('detects numbered and unnumbered fully-qualified handles', () => {
|
||||
expect(parseTopEightText([
|
||||
'I like old websites.',
|
||||
'',
|
||||
'My top 8:',
|
||||
'1. @alice@example.test',
|
||||
'bob@remote.test',
|
||||
'3. @carol@social.example',
|
||||
'',
|
||||
'This remains in the bio.',
|
||||
].join('\n'))).toEqual({
|
||||
handles: ['@alice@example.test', '@bob@remote.test', '@carol@social.example'],
|
||||
bio: 'I like old websites.\n\nThis remains in the bio.',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts local-only @handles in manually written profile lists', () => {
|
||||
expect(parseTopEightText([
|
||||
'About me',
|
||||
'My top 8:',
|
||||
'1. @localfriend',
|
||||
'2. @remote@social.example',
|
||||
'3. @another_local',
|
||||
].join('\n'))).toEqual({
|
||||
handles: ['@localfriend', '@remote@social.example', '@another_local'],
|
||||
bio: 'About me',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores headings without at least one qualified handle', () => {
|
||||
expect(parseTopEightText('My top 8:\nAlice\nBob')).toEqual({
|
||||
handles: [],
|
||||
bio: 'My top 8:\nAlice\nBob',
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces an existing section and caps it at eight unique handles', () => {
|
||||
const next = withTopEight(
|
||||
'Bio\n\nMy top 8:\n@old@example.test',
|
||||
Array.from({ length: 10 }, (_, index) => `friend${index}@example.test`),
|
||||
)
|
||||
expect(next).toContain('Bio\n\nMy top 8:\n1. @friend0@example.test')
|
||||
expect(next).toContain('8. @friend7@example.test')
|
||||
expect(next).not.toContain('friend8')
|
||||
expect(next).not.toContain('@old@example.test')
|
||||
})
|
||||
|
||||
it('removes the section from HTML without flattening the rest of the bio', () => {
|
||||
const result = topEightFromHtml(
|
||||
'<p><strong>Hello!</strong><br>My top 8:<br>1. <a href="https://example.test/@alice">@alice@example.test</a><br>@bob@remote.test</p><p><em>Still here.</em></p>',
|
||||
)
|
||||
expect(result.handles).toEqual(['@alice@example.test', '@bob@remote.test'])
|
||||
expect(result.html).toContain('<strong>Hello!</strong>')
|
||||
expect(result.html).toContain('<em>Still here.</em>')
|
||||
expect(result.html).not.toContain('My top 8')
|
||||
expect(result.html).not.toContain('@alice@example.test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { Account, InstanceInfo } from '../api/types'
|
||||
|
||||
export const TOP_EIGHT_HEADING = 'My top 8:'
|
||||
export const TOP_EIGHT_MAX = 8
|
||||
|
||||
const HEADING_PATTERN = /^\s*my\s+top\s+8\s*:\s*$/i
|
||||
const HANDLE_PATTERN = /^\s*(?:[1-8]\.\s*)?((?:@[a-z0-9_][a-z0-9_.-]*)(?:@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)?|(?:[a-z0-9_][a-z0-9_.-]*@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?))\s*$/i
|
||||
|
||||
export interface ParsedTopEight {
|
||||
handles: string[]
|
||||
/** The bio with only the recognized Top 8 section removed. */
|
||||
bio: string
|
||||
}
|
||||
|
||||
function normalizedHandle(value: string): string | null {
|
||||
const match = HANDLE_PATTERN.exec(value)
|
||||
if (!match) return null
|
||||
return `@${match[1].replace(/^@/, '')}`
|
||||
}
|
||||
|
||||
/** Local-only handles are accepted when reading, but picker-written handles are qualified. */
|
||||
export function topEightHandleMatchesAccount(handle: string, account: Account, localHost: string): boolean {
|
||||
const normalized = handle.toLowerCase()
|
||||
return normalized === `@${account.acct}`.toLowerCase() ||
|
||||
normalized === `@${account.username}`.toLowerCase() ||
|
||||
normalized === `@${account.username}@${localHost}`.toLowerCase()
|
||||
}
|
||||
|
||||
/** Parse the portable, human-readable representation used in profile bios. */
|
||||
export function parseTopEightText(source: string): ParsedTopEight {
|
||||
const normalized = source.replace(/\r\n?/g, '\n')
|
||||
const lines = normalized.split('\n')
|
||||
|
||||
for (let heading = 0; heading < lines.length; heading += 1) {
|
||||
if (!HEADING_PATTERN.test(lines[heading])) continue
|
||||
const handles: string[] = []
|
||||
let end = heading + 1
|
||||
while (end < lines.length && handles.length < TOP_EIGHT_MAX) {
|
||||
const handle = normalizedHandle(lines[end])
|
||||
if (!handle) break
|
||||
handles.push(handle)
|
||||
end += 1
|
||||
}
|
||||
if (handles.length === 0) continue
|
||||
|
||||
const before = lines.slice(0, heading)
|
||||
const after = lines.slice(end)
|
||||
return {
|
||||
handles,
|
||||
bio: [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return { handles: [], bio: normalized.trim() }
|
||||
}
|
||||
|
||||
/** Replace an existing section without disturbing the user's ordinary bio. */
|
||||
export function withTopEight(source: string, handles: string[]): string {
|
||||
const base = parseTopEightText(source).bio
|
||||
const unique = Array.from(
|
||||
new Set(handles.map((handle) => normalizedHandle(handle)).filter((handle): handle is string => Boolean(handle))),
|
||||
).slice(0, TOP_EIGHT_MAX)
|
||||
if (unique.length === 0) return base
|
||||
const section = [TOP_EIGHT_HEADING, ...unique.map((handle, index) => `${index + 1}. ${handle}`)].join('\n')
|
||||
return base ? `${base}\n\n${section}` : section
|
||||
}
|
||||
|
||||
interface ProjectedLine {
|
||||
text: string
|
||||
textNodes: Text[]
|
||||
breaks: HTMLBRElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an HTML bio into lines while retaining the nodes that formed them.
|
||||
* This lets us remove the portable section without flattening links, emphasis,
|
||||
* custom emoji, or any other formatting in the rest of the bio.
|
||||
*/
|
||||
function projectedLines(container: HTMLElement): ProjectedLine[] {
|
||||
const lines: ProjectedLine[] = [{ text: '', textNodes: [], breaks: [] }]
|
||||
const current = () => lines[lines.length - 1]
|
||||
const newline = (br?: HTMLBRElement) => {
|
||||
if (br) current().breaks.push(br)
|
||||
if (current().text || current().textNodes.length || current().breaks.length) {
|
||||
lines.push({ text: '', textNodes: [], breaks: [] })
|
||||
}
|
||||
}
|
||||
const blocks = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4'])
|
||||
|
||||
const visit = (node: Node): void => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? ''
|
||||
const parts = text.split('\n')
|
||||
parts.forEach((part, index) => {
|
||||
if (part) {
|
||||
current().text += part
|
||||
current().textNodes.push(node as Text)
|
||||
}
|
||||
if (index < parts.length - 1) newline()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
if (node.tagName === 'BR') {
|
||||
newline(node as HTMLBRElement)
|
||||
return
|
||||
}
|
||||
const block = blocks.has(node.tagName)
|
||||
if (block && current().text.trim()) newline()
|
||||
for (const child of Array.from(node.childNodes)) visit(child)
|
||||
if (block && current().text.trim()) newline()
|
||||
}
|
||||
|
||||
for (const child of Array.from(container.childNodes)) visit(child)
|
||||
return lines
|
||||
}
|
||||
|
||||
export function topEightFromHtml(source: string): { handles: string[]; html: string } {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = DOMPurify.sanitize(source)
|
||||
const lines = projectedLines(container)
|
||||
|
||||
for (let heading = 0; heading < lines.length; heading += 1) {
|
||||
if (!HEADING_PATTERN.test(lines[heading].text)) continue
|
||||
const handles: string[] = []
|
||||
let end = heading + 1
|
||||
while (end < lines.length && handles.length < TOP_EIGHT_MAX) {
|
||||
const handle = normalizedHandle(lines[end].text)
|
||||
if (!handle) break
|
||||
handles.push(handle)
|
||||
end += 1
|
||||
}
|
||||
if (handles.length === 0) continue
|
||||
|
||||
for (const line of lines.slice(heading, end)) {
|
||||
for (const node of new Set(line.textNodes)) node.textContent = ''
|
||||
for (const br of line.breaks) br.remove()
|
||||
}
|
||||
for (const empty of Array.from(container.querySelectorAll('p, div, li, blockquote, pre'))) {
|
||||
if (!(empty.textContent ?? '').trim() && !empty.querySelector('img')) empty.remove()
|
||||
}
|
||||
return { handles, html: container.innerHTML.trim() }
|
||||
}
|
||||
return { handles: [], html: container.innerHTML.trim() }
|
||||
}
|
||||
|
||||
/** Best available limit. Pleroma does not currently advertise user_bio_length. */
|
||||
export function profileBioLimit(instance: InstanceInfo | null): { value: number; estimated: boolean } {
|
||||
const advertised = instance?.configuration?.accounts?.max_note_length
|
||||
if (typeof advertised === 'number' && advertised > 0) return { value: advertised, estimated: false }
|
||||
if (instance?.pleroma) return { value: 5000, estimated: true }
|
||||
return { value: 500, estimated: true }
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'plspace:top-eight:v1:'
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000
|
||||
|
||||
export function readTopEightCache(host: string, ownerId: string, handles: string[]): Account[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${CACHE_PREFIX}${host}:${ownerId}`)
|
||||
if (!raw) return null
|
||||
const cached = JSON.parse(raw) as { savedAt: number; handles: string[]; accounts: Account[] }
|
||||
if (Date.now() - cached.savedAt > CACHE_TTL || cached.handles.join('\n') !== handles.join('\n')) return null
|
||||
return cached.accounts
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTopEightCache(host: string, ownerId: string, handles: string[], accounts: Account[]): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${CACHE_PREFIX}${host}:${ownerId}`,
|
||||
JSON.stringify({ savedAt: Date.now(), handles, accounts }),
|
||||
)
|
||||
} catch {
|
||||
// Private browsing and storage quotas should never break a public profile.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user