/** * UserProfilePopup — anchored popover that appears when clicking a username * in the chat or member list. Shows avatar, username, role badge, status dot, * about section, join date, and Message/Call action buttons. * * Position: anchored to the click point, flipped to the other side and clamped * against the measured card height so it always lands fully on screen. * Animation: fade+scale, defined in CSS so reduced-motion can drop it. * Close: outside click or Escape. * A11y: role="dialog", aria-label, focus trap, return focus on close. */ import { createElement, appendChildren, setText } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; import { createAvatarElement, resolveDisplayName } from "@lib/avatar"; import { roleColorVar } from "./message-list/formatting"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface UserProfileData { readonly id: number; readonly username: string; readonly avatar: string | null; readonly role: string; readonly status: UserStatus; /** Nickname. When set the popup shows it as the heading and the username * underneath, because the username is still the handle you @mention. */ readonly displayName?: string | null; readonly about?: string | null; /** Free-text status line, shown under the name. */ readonly customStatus?: string | null; readonly joinDate?: string | null; readonly isDeleted?: boolean; } export interface UserProfilePopupOptions { readonly user: UserProfileData; /** Anchor point — the click event's clientX/clientY. */ readonly anchorX: number; readonly anchorY: number; /** Called when the user clicks "Message". */ readonly onMessage?: (userId: number) => void; /** Called when the user clicks "Call". */ readonly onCall?: (userId: number) => void; } export type UserProfilePopupComponent = MountableComponent & { /** Check if the popup is currently visible. */ isOpen(): boolean; }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const POPUP_WIDTH = 300; /** Keeps the card clear of the window edges on both axes. */ const VIEWPORT_MARGIN = 8; /** Breathing room between the click point and the card. */ const ANCHOR_GAP = 8; const STATUS_COLORS: Record = { online: "#3ba55d", idle: "#faa61a", dnd: "#ed4245", // Only ever reached for the signed-in user looking at their own profile — // the server maps invisible to offline for everyone else. invisible: "#747f8d", offline: "#747f8d", }; const STATUS_LABELS: Record = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", invisible: "Invisible", offline: "Offline", }; // --------------------------------------------------------------------------- // Component factory // --------------------------------------------------------------------------- export function createUserProfilePopup( options: UserProfilePopupOptions, ): UserProfilePopupComponent { const ac = new AbortController(); const { signal } = ac; let overlay: HTMLDivElement | null = null; let popup: HTMLDivElement | null = null; let previousFocus: Element | null = null; function isOpen(): boolean { return popup !== null && overlay !== null; } function close(): void { if (overlay !== null) { overlay.remove(); overlay = null; } popup = null; ac.abort(); // Return focus to the element that was focused before opening if (previousFocus instanceof HTMLElement) { previousFocus.focus(); } } /** * Place the card beside the anchor, flipping and clamping so it always lands * fully on screen — Discord opens its popout away from whichever edge the * clicked row is nearest. * * The height is measured rather than assumed. The previous version guessed * 300px and only clamped the top edge, so a member clicked low in the list * opened a card that ran off the bottom of the window. */ function position(el: HTMLElement, anchorX: number, anchorY: number): void { const vw = window.innerWidth; const vh = window.innerHeight; const height = el.offsetHeight; // Prefer the right of the anchor and flip left when there is no room. The // member list sits against the right edge, so flipping is the usual case. let left = anchorX + ANCHOR_GAP; if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) { left = anchorX - POPUP_WIDTH - ANCHOR_GAP; } left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN)); // Align the top with the click, then lift the card just enough to fit. let top = anchorY; if (top + height > vh - VIEWPORT_MARGIN) { top = vh - height - VIEWPORT_MARGIN; } top = Math.max(VIEWPORT_MARGIN, top); el.style.left = `${left}px`; el.style.top = `${top}px`; } function buildAvatar(user: UserProfileData): HTMLDivElement { // The shared helper is what makes uploaded avatars work here and in the // message rows and member list at the same time: it fetches the // authenticated file through the cert-pinned path and falls back to the // letter until (or unless) the bytes arrive. const wrapper = createAvatarElement( { username: user.username, displayName: user.displayName, avatar: user.avatar, isDeleted: user.isDeleted, }, { className: "upp-avatar", background: user.isDeleted === true ? "#4e5058" : "var(--accent, #5865f2)", }, ); // Status dot overlay const statusDot = createElement("div", { class: "upp-status-dot" }); statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline; statusDot.title = STATUS_LABELS[user.status] ?? "Offline"; wrapper.appendChild(statusDot); return wrapper; } function mount(container: Element): void { previousFocus = document.activeElement; const user = options.user; const displayName = user.isDeleted === true ? "[deleted]" : resolveDisplayName(user); // Overlay for outside-click detection overlay = createElement("div", { class: "upp-overlay", "data-testid": "user-profile-overlay", }); // Popup container popup = createElement("div", { class: "upp-popup", role: "dialog", "aria-label": "User profile", "aria-modal": "true", tabindex: "-1", "data-testid": "user-profile-popup", }); popup.style.width = `${POPUP_WIDTH}px`; // --- Content --- // Avatar const avatar = buildAvatar(user); // Username const nameEl = createElement("div", { class: "upp-username" }, displayName); if (user.isDeleted === true) { nameEl.style.color = "var(--text-faint, #80848e)"; } // Username line, shown only when a display name is standing in for it. // @mentions still resolve by username, so the popup has to keep telling // you what to type. const handleEl = createElement("div", { class: "upp-username-handle" }); if (user.isDeleted !== true && displayName !== user.username) { setText(handleEl, `@${user.username}`); } // Custom status line — the user's own words, under the name. const customStatusEl = createElement("div", { class: "upp-custom-status" }); if (typeof user.customStatus === "string" && user.customStatus.length > 0) { setText(customStatusEl, user.customStatus); } // Role badge const roleBadge = createElement("span", { class: "upp-role-badge" }); const roleDot = createElement("span", { class: "upp-role-dot" }); roleDot.style.background = roleColorVar(user.role.toLowerCase()); const roleLabel = createElement( "span", {}, user.role.charAt(0).toUpperCase() + user.role.slice(1), ); appendChildren(roleBadge, roleDot, roleLabel); // Status line const statusLine = createElement("div", { class: "upp-status-line" }); const statusDotInline = createElement("span", { class: "upp-status-dot-inline" }); statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline; const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline"); appendChildren(statusLine, statusDotInline, statusText); // About section (2 lines max) const aboutSection = createElement("div", { class: "upp-about" }); if (user.about !== undefined && user.about !== null && user.about.length > 0) { const aboutTitle = createElement("div", { class: "upp-section-title" }, "ABOUT ME"); const aboutText = createElement("div", { class: "upp-about-text" }, user.about); appendChildren(aboutSection, aboutTitle, aboutText); } // Join date const joinSection = createElement("div", { class: "upp-join-date" }); if (user.joinDate !== undefined && user.joinDate !== null) { const joinTitle = createElement("div", { class: "upp-section-title" }, "MEMBER SINCE"); const joinText = createElement("div", { class: "upp-join-text" }, user.joinDate); appendChildren(joinSection, joinTitle, joinText); } // Divider const divider = createElement("div", { class: "upp-divider" }); // Actions — only render buttons that are actually wired up, so the popup // never shows a dead control (e.g. Call before DM calls exist, or Message // on your own profile). const actions = createElement("div", { class: "upp-actions" }); if (options.onMessage !== undefined) { const onMessage = options.onMessage; const messageBtn = createElement("button", { class: "upp-action-btn", "data-testid": "upp-message-btn", }); messageBtn.appendChild(createIcon("send", 16)); messageBtn.appendChild(document.createTextNode(" Message")); messageBtn.addEventListener( "click", () => { onMessage(user.id); close(); }, { signal }, ); actions.appendChild(messageBtn); } if (options.onCall !== undefined) { const onCall = options.onCall; const callBtn = createElement("button", { class: "upp-action-btn", "data-testid": "upp-call-btn", }); callBtn.appendChild(createIcon("phone", 16)); callBtn.appendChild(document.createTextNode(" Call")); callBtn.addEventListener( "click", () => { onCall(user.id); close(); }, { signal }, ); actions.appendChild(callBtn); } // Assemble the card: a banner strip and a body, with the avatar straddling // the seam between them the way Discord's popout does. const banner = createElement("div", { class: "upp-banner" }); const body = createElement("div", { class: "upp-body" }); appendChildren( body, nameEl, handleEl, customStatusEl, roleBadge, statusLine, aboutSection, joinSection, ); if (actions.childElementCount > 0) { appendChildren(body, divider, actions); } // The avatar hangs off the body's top edge, so it is a child of the card // rather than the body — the body scrolls, and a scroll container clips. // Appending it last puts it over the banner without needing a z-index. appendChildren(popup, banner, body, avatar); overlay.appendChild(popup); container.appendChild(overlay); // Measure, then place: the card has to be in the document before it has a // height to clamp against. position(popup, options.anchorX, options.anchorY); // The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it. requestAnimationFrame(() => { if (popup !== null) { popup.classList.add("open"); } }); // Focus the popup for a11y popup.focus(); // Close on outside click (click on overlay but not on popup) overlay.addEventListener( "mousedown", (e: MouseEvent) => { if (popup !== null && !popup.contains(e.target as Node)) { close(); } }, { signal }, ); // Close on Escape document.addEventListener( "keydown", (e: KeyboardEvent) => { if (e.key === "Escape" && isOpen()) { close(); } }, { signal }, ); // Focus trap: keep focus inside popup popup.addEventListener( "keydown", (e: KeyboardEvent) => { if (e.key !== "Tab" || popup === null) return; const focusable = popup.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; const first = focusable[0]!; const last = focusable[focusable.length - 1]!; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }, { signal }, ); } function destroy(): void { close(); } return { mount, destroy, isOpen }; }