initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+224
View File
@@ -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
}