Files
OwnCord/Client/src/components/UserProfilePopup.ts
T

396 lines
13 KiB
TypeScript

/**
* 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<UserStatus, string> = {
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<UserStatus, string> = {
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<HTMLElement>(
'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 };
}