Files
plspace/src/lib/util/profile.ts
T
2026-08-03 16:40:37 +09:00

235 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Mapping ActivityPub accounts onto a 2005 profile page.
*
* A MySpace profile had a fixed vocabulary — a headline in quotes, a mood, an
* "Interests" table with General/Music/Movies/Television/Books/Heroes rows, and
* blurbs for "About me" and "Who I'd like to meet". Mastodon has none of that;
* it has a bio and up to four free-form key/value fields.
*
* The mapping is: profile fields whose name matches a known MySpace row fill
* that row, leftover fields land in a generic details table, and the bio is
* split into the two blurbs on a "who I'd like to meet"-ish heading if the user
* wrote one. Users opt in to the richer layout simply by naming their fields
* `Music`, `Movies`, `Mood` and so on.
*/
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
export type InterestRow = (typeof INTEREST_ROWS)[number]
/** Field names that feed the chrome rather than a table row. */
const CHROME_FIELDS = new Set(['headline', 'mood', 'status', 'location', 'city', 'gender', 'pronouns', 'age'])
const ALIASES: Record<string, InterestRow> = {
general: 'General',
interests: 'General',
about: 'General',
music: 'Music',
bands: 'Music',
'now playing': 'Music',
movies: 'Movies',
film: 'Movies',
films: 'Movies',
television: 'Television',
tv: 'Television',
shows: 'Television',
books: 'Books',
reading: 'Books',
heroes: 'Heroes',
hero: 'Heroes',
inspiration: 'Heroes',
}
export interface ProfileField {
name: string
/** Sanitized HTML. */
value: string
verified: boolean
}
export interface InterestEntry {
row: InterestRow
/** Sanitized HTML. */
value: string
}
export interface ProfileView {
account: Account
/** The quoted line beside the photo. */
headline: string
mood: string | null
location: string | null
gender: string | null
/** Age in years, from the account creation date unless a field overrides it. */
age: number | null
/** "About me" blurb, sanitized HTML. */
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[]
}
/** Split a bio on a "who I'd like to meet" style heading, if one exists. */
function splitBio(noteHtml: string): { about: string; meet: string } {
const marker = /(?:^|\n|<br\s*\/?>|<\/p>\s*<p>)\s*(?:who\s+i(?:'|)?d\s+like\s+to\s+meet|looking\s+for)\s*:?/i
const match = marker.exec(noteHtml)
if (!match || match.index === undefined) return { about: noteHtml, meet: '' }
return {
about: noteHtml.slice(0, match.index),
meet: noteHtml.slice(match.index + match[0].length),
}
}
function findField(account: Account, names: string[]): string | null {
for (const field of account.fields ?? []) {
if (names.includes(field.name.trim().toLowerCase())) {
const text = toPlainText(field.value)
if (text) return text
}
}
return null
}
/**
* A stable, silly mood per account — MySpace always showed one, and an empty
* "Mood:" line reads as a bug. Derived from the account id so it doesn't
* flicker between renders.
*/
const MOODS = [
'busy :-)',
'productive :)',
'awake',
'jubilant',
'chill',
'working',
'contemplative',
'amused ;)',
'nostalgic',
'bouncy',
'sleepy',
'accomplished',
]
export function fallbackMood(seed: string): string {
let hash = 0
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
}
return MOODS[hash % MOODS.length]
}
export function buildProfileView(account: Account): ProfileView {
const renderedNote = renderHtml(account.note, { emojis: account.emojis })
const topEight = topEightFromHtml(renderedNote)
const noteHtml = topEight.html
const { about, meet } = splitBio(noteHtml)
const interests: InterestEntry[] = []
const details: ProfileField[] = []
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 })
const row = ALIASES[key]
if (row) {
interests.push({ row, value })
} else {
details.push({
name: field.name,
value,
verified: Boolean(field.verified_at),
})
}
}
// Keep the canonical MySpace ordering rather than the user's field order.
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(noteHtml)) ?? '"..."'
const ageField = findField(account, ['age'])
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
return {
account,
headline,
mood: findField(account, ['mood']) ?? fallbackMood(account.id || account.acct),
location: findField(account, ['location', 'city']),
gender: findField(account, ['gender', 'pronouns']),
age: Number.isFinite(parsedAge) ? parsedAge : null,
about,
wantsToMeet: meet,
topEightHandles: topEight.handles,
interests,
details,
}
}
function firstSentence(text: string): string | null {
if (!text) return null
const match = /^.{0,120}?[.!?](?:\s|$)/.exec(text)
const sentence = (match?.[0] ?? text.slice(0, 120)).trim()
return sentence || null
}
/** `15,672,442` — friend counts were the whole point. */
export function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined || value < 0) return '0'
return value.toLocaleString('en-US')
}
/** `@user@host`, always with the domain so remote accounts are unambiguous. */
export function fullHandle(account: Account, localHost: string): string {
return account.acct.includes('@') ? `@${account.acct}` : `@${account.acct}@${localHost}`
}
/**
* Whether an account's follower list is withheld.
*
* Mastodon signals this with `hide_collections`; Pleroma and Akkoma use
* `pleroma.hide_followers` instead. Both then return an empty list, which is
* indistinguishable from having no followers unless you check the flag.
*/
export function followersHidden(account: Account): boolean {
return Boolean(account.hide_collections || account.pleroma?.hide_followers)
}
/**
* Whether the follower *count* is withheld.
*
* Pleroma reports `followers_count: 0` when `hide_followers_count` is set, so
* rendering that zero would state a privacy setting as fact.
*/
export function followerCountHidden(account: Account): boolean {
return Boolean(account.pleroma?.hide_followers_count)
}
/** As above, for the accounts someone follows. */
export function followingCountHidden(account: Account): boolean {
return Boolean(account.pleroma?.hide_follows_count)
}
/** The route this app uses for an account. */
export function profilePath(account: Account): string {
return `#/@${account.acct}`
}
export function displayNameOf(account: Account): string {
return account.display_name?.trim() || account.username
}