mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
/**
|
||
* 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
|
||
}
|