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>
133 lines
5.4 KiB
TypeScript
133 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { render } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import type { User } from '@backspace/shared';
|
|
|
|
// The popout reaches into the space store (origin routing), the API client and
|
|
// the federated-mutuals loader. None of that is under test here — stub it so the
|
|
// test exercises the card's own click and placement behaviour.
|
|
vi.mock('../../stores/spaceStore', () => ({
|
|
useSpaceStore: Object.assign(
|
|
(selector: (s: Record<string, unknown>) => unknown) =>
|
|
// 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',
|
|
}));
|
|
vi.mock('../../api/client', () => ({ api: { dm: { create: vi.fn() } } }));
|
|
vi.mock('../../utils/mutuals', () => ({
|
|
loadFederatedMutuals: vi.fn().mockResolvedValue({ mutualFriends: [], mutualSpaces: [] }),
|
|
}));
|
|
vi.mock('../../utils/userViewLookup', () => ({ useCanonicalUserView: (u: User) => u }));
|
|
|
|
import { UserProfilePopout } from './UserProfilePopout';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
|
|
const CARD_W = 340;
|
|
const CARD_H = 420;
|
|
|
|
function makeUser(): User {
|
|
return {
|
|
id: 'u-1', username: 'ada', displayName: 'Ada', avatar: null, banner: null,
|
|
accentColor: null, avatarColor: null, bio: null, status: 'online',
|
|
customStatus: null, isAdmin: false, createdAt: 0, homeInstance: null,
|
|
homeUserId: null, replicatedInstances: [],
|
|
};
|
|
}
|
|
|
|
function anchorAt(left: number, top: number, size = 40) {
|
|
return { top, left, right: left + size, bottom: top + size, width: size, height: size };
|
|
}
|
|
|
|
function setViewport(width: number, height: number) {
|
|
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
|
|
Object.defineProperty(window, 'innerHeight', { value: height, configurable: true });
|
|
}
|
|
|
|
function renderCard(anchor: ReturnType<typeof anchorAt>, placement?: 'left' | 'right') {
|
|
return render(
|
|
<MemoryRouter>
|
|
<UserProfilePopout user={makeUser()} onClose={() => {}} anchor={anchor} placement={placement} />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
describe('UserProfilePopout', () => {
|
|
beforeEach(() => {
|
|
setViewport(1920, 1080);
|
|
useUIStore.setState({
|
|
isMobile: false,
|
|
activeModal: null,
|
|
modalData: {},
|
|
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
|
});
|
|
// jsdom has no layout: give every element the card's real measured size so
|
|
// the popout can place itself off its own dimensions.
|
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
top: 0, left: 0, right: CARD_W, bottom: CARD_H, width: CARD_W, height: CARD_H,
|
|
x: 0, y: 0, toJSON: () => ({}),
|
|
} as DOMRect);
|
|
});
|
|
|
|
it('does not reopen itself when its own picture is clicked (issue #37)', async () => {
|
|
const { container } = renderCard(anchorAt(300, 200));
|
|
|
|
const avatar = container.querySelector('[data-avatar]')!;
|
|
await userEvent.click(avatar);
|
|
await userEvent.click(avatar);
|
|
|
|
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
|
});
|
|
|
|
it("escalates to the full profile when the card's picture is clicked", async () => {
|
|
// The picture is the obvious thing to click for "show me more about this
|
|
// person". Doing nothing there is a dead end — the only way forward would be
|
|
// the View Full Profile link.
|
|
let closed = false;
|
|
const { container } = render(
|
|
<MemoryRouter>
|
|
<UserProfilePopout user={makeUser()} onClose={() => { closed = true; }} anchor={anchorAt(300, 200)} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
await userEvent.click(container.querySelector('[data-avatar]')!);
|
|
|
|
expect(useUIStore.getState().activeModal).toBe('userProfile');
|
|
expect(useUIStore.getState().modalData).toMatchObject({ userId: 'u-1' });
|
|
expect(closed).toBe(true);
|
|
});
|
|
|
|
it('sits beside its anchor', () => {
|
|
const { container } = renderCard(anchorAt(300, 200));
|
|
|
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
|
expect(parseFloat(card.style.left)).toBe(340 + 8); // anchor.right + offset
|
|
expect(parseFloat(card.style.top)).toBe(200); // top-aligned with its anchor
|
|
});
|
|
|
|
it('flips to the other side instead of running off the right edge', () => {
|
|
setViewport(1000, 800);
|
|
const { container } = renderCard(anchorAt(900, 100));
|
|
|
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
|
const left = parseFloat(card.style.left);
|
|
expect(left).toBeGreaterThanOrEqual(8);
|
|
expect(left + CARD_W).toBeLessThanOrEqual(1000 - 8);
|
|
});
|
|
|
|
it('keeps a tall card on screen when anchored near the bottom', () => {
|
|
setViewport(1280, 700);
|
|
const { container } = renderCard(anchorAt(200, 660));
|
|
|
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
|
const top = parseFloat(card.style.top);
|
|
expect(top).toBeGreaterThanOrEqual(8);
|
|
expect(top + CARD_H).toBeLessThanOrEqual(700 - 8);
|
|
});
|
|
});
|