mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** 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
|
||||
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 noteHtml = renderHtml(account.note, { emojis: account.emojis })
|
||||
const { about, meet } = splitBio(noteHtml)
|
||||
|
||||
const interests: InterestEntry[] = []
|
||||
const details: ProfileField[] = []
|
||||
|
||||
for (const field of account.fields ?? []) {
|
||||
const key = field.name.trim().toLowerCase()
|
||||
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(account.note)) ?? '"..."'
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user