mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
396 lines
13 KiB
TypeScript
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 };
|
|
}
|