/** * 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 = { 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||<\/p>\s*

)\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 }