feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.
What a user can now set, and where it shows:
- Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
- Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
hearts, frost, crown). Inline SVG — no image assets, scales to any size.
- Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
positions so re-renders do not change the weather.
- Nameplate behind the member-list row: 10 presets + custom two-colour.
- Theme: two colours washed behind the card, and the banner fallback.
- Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
also on the member row.
- Links, up to 5, with the brand icon picked from the host.
- Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
Games come from Steam search through a small server proxy (cached,
10 min) or are typed by hand; covers only load from Steam's CDN.
- Badges, derived rather than stored: instance admin, server owner,
founder (joined in the space's first month), veteran (a year), OG (six
months), stylist. Nothing to grant, revoke or migrate.
Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.
The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.
Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.
Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,9 @@
|
||||
"./src/audit": "./src/audit.ts",
|
||||
"./src/audit.js": "./src/audit.ts",
|
||||
"./src/mentions": "./src/mentions.ts",
|
||||
"./src/mentions.js": "./src/mentions.ts"
|
||||
"./src/mentions.js": "./src/mentions.ts",
|
||||
"./src/profile": "./src/profile.ts",
|
||||
"./src/profile.js": "./src/profile.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Profile identity: everything a user can style about how they appear, beyond
|
||||
* the avatar/banner/bio fields that predate it.
|
||||
*
|
||||
* Stored as one JSON column (`users.profile_style`) rather than a dozen
|
||||
* columns: the shape will keep growing (new effects, new widgets), every field
|
||||
* is optional, and nothing here is ever queried by value. The server
|
||||
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
|
||||
* always a complete, bounded, well-formed object.
|
||||
*
|
||||
* Catalog ids are strings on purpose — a client that does not know an id
|
||||
* (older build, other instance) falls back to `none`/`default` instead of
|
||||
* crashing on an enum it has never seen.
|
||||
*/
|
||||
|
||||
export const NAME_FONTS = [
|
||||
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
|
||||
] as const;
|
||||
export type NameFont = (typeof NAME_FONTS)[number];
|
||||
|
||||
export const NAME_EFFECTS = [
|
||||
'solid', 'gradient', 'neon', 'toon', 'pop', 'gummy', 'prism', 'glitch', 'fire',
|
||||
] as const;
|
||||
export type NameEffect = (typeof NAME_EFFECTS)[number];
|
||||
|
||||
export const AVATAR_DECORATIONS = [
|
||||
'none', 'ring', 'neon', 'flames', 'stars', 'halo', 'vines', 'pixel', 'hearts', 'frost', 'crown',
|
||||
] as const;
|
||||
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
|
||||
|
||||
export const PROFILE_EFFECTS = [
|
||||
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
|
||||
] as const;
|
||||
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
|
||||
|
||||
export const NAMEPLATES = [
|
||||
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight', 'custom',
|
||||
] as const;
|
||||
export type Nameplate = (typeof NAMEPLATES)[number];
|
||||
|
||||
export interface NameStyle {
|
||||
font: NameFont;
|
||||
effect: NameEffect;
|
||||
/** Two hex colours. Solid uses the first; every other effect uses both. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface ProfileTheme {
|
||||
/** Two hex colours behind the card and the banner fallback. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface ProfileTag {
|
||||
/** 2–4 characters, shown uppercase in a chip next to the name. */
|
||||
text: string;
|
||||
/** Single emoji (grapheme) in front of the text; empty for none. */
|
||||
emoji: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProfileLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameEntry {
|
||||
/** Steam app id when picked from search; null for a free-typed title. */
|
||||
appId: number | null;
|
||||
name: string;
|
||||
/** https URL of a cover image (Steam CDN when picked from search). */
|
||||
cover: string | null;
|
||||
note: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ProfileQuote {
|
||||
text: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface ProfileWidgets {
|
||||
favoriteGame: GameEntry | null;
|
||||
rotation: GameEntry[];
|
||||
wishlist: GameEntry[];
|
||||
quote: ProfileQuote | null;
|
||||
}
|
||||
|
||||
export interface ProfileStyle {
|
||||
name: NameStyle;
|
||||
theme: ProfileTheme | null;
|
||||
decoration: AvatarDecoration;
|
||||
effect: ProfileEffect;
|
||||
nameplate: Nameplate;
|
||||
/** Only read when nameplate === 'custom'. */
|
||||
nameplateColors: [string, string];
|
||||
pronouns: string;
|
||||
tag: ProfileTag | null;
|
||||
links: ProfileLink[];
|
||||
widgets: ProfileWidgets;
|
||||
}
|
||||
|
||||
export const PROFILE_LIMITS = {
|
||||
pronouns: 40,
|
||||
tagMin: 2,
|
||||
tagMax: 4,
|
||||
links: 5,
|
||||
linkLabel: 24,
|
||||
linkUrl: 200,
|
||||
gameName: 80,
|
||||
gameNote: 80,
|
||||
gameTags: 3,
|
||||
gameTag: 16,
|
||||
rotation: 5,
|
||||
wishlist: 10,
|
||||
quoteText: 140,
|
||||
quoteAuthor: 40,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
|
||||
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
|
||||
theme: null,
|
||||
decoration: 'none',
|
||||
effect: 'none',
|
||||
nameplate: 'none',
|
||||
nameplateColors: ['#7c6cf6', '#f472b6'],
|
||||
pronouns: '',
|
||||
tag: null,
|
||||
links: [],
|
||||
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
|
||||
};
|
||||
|
||||
// ─── Normalisation ────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
/** Domains whose images a profile may embed. Anything else is dropped, not
|
||||
* proxied — a profile is not a place to load arbitrary third-party pixels. */
|
||||
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
|
||||
|
||||
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
function hex(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
function str(value: unknown, max: number): string {
|
||||
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
}
|
||||
function httpsUrl(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const v = value.trim();
|
||||
if (v.length === 0 || v.length > max) return null;
|
||||
try {
|
||||
const u = new URL(v);
|
||||
return u.protocol === 'https:' ? u.toString() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function coverUrl(value: unknown): string | null {
|
||||
const u = httpsUrl(value, 300);
|
||||
if (!u) return null;
|
||||
try { return COVER_HOSTS.includes(new URL(u).hostname) ? u : null; } catch { return null; }
|
||||
}
|
||||
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
|
||||
function oneGrapheme(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const s = value.trim();
|
||||
if (!s) return '';
|
||||
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
|
||||
if (seg) {
|
||||
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
|
||||
}
|
||||
return Array.from(s)[0] ?? '';
|
||||
}
|
||||
function game(value: unknown): GameEntry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
const name = str(v.name, PROFILE_LIMITS.gameName);
|
||||
if (!name) return null;
|
||||
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
|
||||
const tags = Array.isArray(v.tags)
|
||||
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
|
||||
: [];
|
||||
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
|
||||
}
|
||||
function games(value: unknown, max: number): GameEntry[] {
|
||||
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
|
||||
* back to defaults, over-long strings are cut, bad colours are replaced, bad
|
||||
* URLs are dropped. The server stores exactly this output.
|
||||
*/
|
||||
export function normalizeProfileStyle(input: unknown): ProfileStyle {
|
||||
const d = DEFAULT_PROFILE_STYLE;
|
||||
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
||||
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
|
||||
const nameColors = Array.isArray(name.colors) ? name.colors : [];
|
||||
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
|
||||
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
|
||||
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
|
||||
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
|
||||
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
|
||||
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
|
||||
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
|
||||
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
|
||||
|
||||
const links: ProfileLink[] = Array.isArray(v.links)
|
||||
? v.links.map((l) => {
|
||||
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
|
||||
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
|
||||
if (!url) return null;
|
||||
const label = str(o.label, PROFILE_LIMITS.linkLabel) || new URL(url).hostname.replace(/^www\./, '');
|
||||
return { label, url };
|
||||
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: {
|
||||
font: pick(name.font, NAME_FONTS, d.name.font),
|
||||
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
|
||||
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
|
||||
},
|
||||
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
|
||||
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
|
||||
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
|
||||
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
|
||||
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
|
||||
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
|
||||
tag: tagText.length >= PROFILE_LIMITS.tagMin
|
||||
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
|
||||
: null,
|
||||
links,
|
||||
widgets: {
|
||||
favoriteGame: game(widgets.favoriteGame),
|
||||
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
|
||||
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
|
||||
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a style differs from the defaults — used to skip rendering work. */
|
||||
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
|
||||
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
|
||||
}
|
||||
|
||||
/** Steam's poster-format cover for an app id. */
|
||||
export function steamCoverUrl(appId: number): string {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface User {
|
||||
showActivity?: boolean;
|
||||
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
|
||||
federationHomeOrphaned?: boolean;
|
||||
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface ReplicatedInstance {
|
||||
@@ -591,6 +593,8 @@ export interface UpdateUserRequest {
|
||||
profileUpdatedAt?: number;
|
||||
discoverable?: boolean;
|
||||
showActivity?: boolean;
|
||||
/** Normalised server-side; pass null to reset to defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRequest {
|
||||
|
||||
Reference in New Issue
Block a user