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:
@@ -10,8 +10,11 @@ import type { User } from '@backspace/shared';
|
||||
vi.mock('../../stores/spaceStore', () => ({
|
||||
useSpaceStore: Object.assign(
|
||||
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }),
|
||||
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }) },
|
||||
// The card now reads the space roster to derive badges; the stub must
|
||||
// carry the same shape the store does, or the selectors dereference
|
||||
// undefined and the failure looks like a component bug.
|
||||
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }),
|
||||
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }) },
|
||||
),
|
||||
getApiForOrigin: () => ({ uploads: { url: (k: string) => `/uploads/${k}` } }),
|
||||
resolveUserOrigin: () => 'local',
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Username } from '../ui/Username';
|
||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||
import { api } from '../../api/client';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
|
||||
import { parseFederatedUsername } from '../../utils/identity';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { loadFederatedMutuals } from '../../utils/mutuals';
|
||||
import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition';
|
||||
import { ProfileCard } from '../profile/ProfileCard';
|
||||
import { getProfileStyle } from '../../utils/profileStyle';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
/** Gap between the card and the element it was opened from. */
|
||||
const ANCHOR_OFFSET = 8;
|
||||
@@ -26,6 +25,7 @@ interface UserProfilePopoutProps {
|
||||
|
||||
export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
// Resolve to the best-known view of this user from the userViews cache.
|
||||
@@ -119,12 +119,12 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
const bannerSrc = user.banner
|
||||
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
|
||||
: null;
|
||||
const bannerFallback = user.accentColor
|
||||
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||
: (() => {
|
||||
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||
return mutedGradient(g.from, g.to);
|
||||
})();
|
||||
const style = getProfileStyle(user);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const space = spaces.find((sp) => sp.id === currentSpaceId) ?? null;
|
||||
const member = members.find((m) => m.userId === user.id) ?? null;
|
||||
|
||||
// Parked off-screen for the one layout pass before the card knows how tall it
|
||||
// is; `useLayoutEffect` places it before the browser paints, so it never
|
||||
@@ -138,81 +138,22 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
|
||||
style={cardStyle}
|
||||
>
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="h-[80px] rounded-t-[12px]"
|
||||
style={bannerSrc
|
||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: bannerFallback }
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 pb-4 relative">
|
||||
{/* Avatar */}
|
||||
{/* The picture escalates to the full profile — the card is a preview, and
|
||||
clicking the face is the obvious way to ask for the whole thing. It
|
||||
deliberately does NOT reopen the card (see issue #37). */}
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={80}
|
||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||
userId={user.homeUserId ?? user.id}
|
||||
user={user}
|
||||
onClick={handleAvatarClick}
|
||||
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
||||
className="mt-[-44px] mb-3"
|
||||
/>
|
||||
|
||||
{/* Name & info */}
|
||||
<div>
|
||||
<Username
|
||||
username={user.displayName ?? baseName}
|
||||
className="text-[16px] font-semibold leading-tight"
|
||||
/>
|
||||
<div className="text-[13px] text-txt-tertiary">
|
||||
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
|
||||
</div>
|
||||
{user.customStatus && (
|
||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||
{user.customStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
{user.bio && (
|
||||
<>
|
||||
<div className="border-t border-white/[0.06] my-3" />
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
About Me
|
||||
</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ProfileCard
|
||||
user={user}
|
||||
style={style}
|
||||
displayName={displayName}
|
||||
bannerSrc={bannerSrc}
|
||||
space={space}
|
||||
member={member}
|
||||
onAvatarClick={handleAvatarClick}
|
||||
>
|
||||
<div className="border-t border-white/[0.06] my-3" />
|
||||
|
||||
{/* Member since + Mutuals */}
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
Member Since
|
||||
{t('profile.memberSince')}
|
||||
</span>
|
||||
<span className="text-[12px] text-txt-secondary ml-2">
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
@@ -238,15 +179,15 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
onClick={handleSendMessage}
|
||||
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
|
||||
>
|
||||
Send Message
|
||||
{t('profile.sendMessage')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleViewFullProfile}
|
||||
className="w-full mt-1.5 py-2 rounded-lg text-[13px] font-medium text-txt-tertiary hover:text-txt-secondary bg-transparent hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
View Full Profile
|
||||
{t('profile.viewFull')}
|
||||
</button>
|
||||
</div>
|
||||
</ProfileCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user