2026-03-31 19:13:05 +02:00
|
|
|
/**
|
|
|
|
|
* StatusPicker — compact dropdown for selecting user online status.
|
|
|
|
|
* Shows a colored status dot; clicking it opens a floating menu
|
|
|
|
|
* with all status options. Intended for use in the UserBar.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createElement, appendChildren } from "@lib/dom";
|
|
|
|
|
import { createIcon } from "@lib/icons";
|
|
|
|
|
import type { MountableComponent } from "@lib/safe-render";
|
|
|
|
|
import type { UserStatus } from "@lib/types";
|
2026-08-01 22:06:14 +02:00
|
|
|
import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus";
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Types
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export interface StatusPickerOptions {
|
|
|
|
|
readonly currentStatus: UserStatus;
|
|
|
|
|
readonly onStatusChange: (status: UserStatus) => void;
|
2026-08-01 22:06:14 +02:00
|
|
|
/** The custom status line to pre-fill the input with. */
|
|
|
|
|
readonly currentCustomStatus?: string;
|
|
|
|
|
/** Called when the user commits a custom status (Enter or blur). Passing an
|
|
|
|
|
* empty string means "clear it". Omitted = the input is not rendered. */
|
|
|
|
|
readonly onCustomStatusChange?: (text: string) => void;
|
2026-03-31 19:13:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type StatusPickerComponent = MountableComponent & {
|
|
|
|
|
/** Update the displayed status without recreating the picker. */
|
|
|
|
|
setStatus(status: UserStatus): void;
|
2026-08-01 22:06:14 +02:00
|
|
|
/** Update the custom status input without recreating the picker. */
|
|
|
|
|
setCustomStatus(text: string): void;
|
2026-03-31 19:13:05 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Status definitions
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface StatusDef {
|
|
|
|
|
readonly value: UserStatus;
|
|
|
|
|
readonly label: string;
|
|
|
|
|
readonly color: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
/**
|
|
|
|
|
* "invisible" is its own value now, not "offline" wearing a different label.
|
|
|
|
|
* The server stores it as chosen and shows everyone else offline, so the
|
|
|
|
|
* picker can finally send what it means — and the status survives a reconnect
|
|
|
|
|
* instead of flashing back to online.
|
|
|
|
|
*/
|
2026-03-31 19:13:05 +02:00
|
|
|
const STATUS_DEFS: readonly StatusDef[] = [
|
2026-04-01 11:39:30 +02:00
|
|
|
{ value: "online", label: "Online", color: "#3ba55d" },
|
|
|
|
|
{ value: "idle", label: "Idle", color: "#faa61a" },
|
|
|
|
|
{ value: "dnd", label: "Do Not Disturb", color: "#ed4245" },
|
2026-08-01 22:06:14 +02:00
|
|
|
{ value: "invisible", label: "Invisible", color: "#747f8d" },
|
2026-03-31 19:13:05 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function colorForStatus(status: UserStatus): string {
|
|
|
|
|
return STATUS_DEFS.find((d) => d.value === status)?.color ?? "#747f8d";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Component factory
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-01 11:39:30 +02:00
|
|
|
export function createStatusPicker(options: StatusPickerOptions): StatusPickerComponent {
|
2026-03-31 19:13:05 +02:00
|
|
|
const ac = new AbortController();
|
|
|
|
|
const { signal } = ac;
|
|
|
|
|
|
|
|
|
|
let currentStatus: UserStatus = options.currentStatus;
|
|
|
|
|
let root: HTMLDivElement | null = null;
|
|
|
|
|
let dotEl: HTMLDivElement | null = null;
|
|
|
|
|
let dropdownEl: HTMLDivElement | null = null;
|
|
|
|
|
let checkEls = new Map<UserStatus, HTMLSpanElement>();
|
2026-08-01 22:06:14 +02:00
|
|
|
let customInputEl: HTMLInputElement | null = null;
|
|
|
|
|
/** Last text handed to the callback. Guards the blur-after-Enter double
|
|
|
|
|
* send, which would otherwise cost a second presence_update against the
|
|
|
|
|
* server's one-per-ten-seconds limit. */
|
|
|
|
|
let lastCommittedCustom = options.currentCustomStatus ?? "";
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
// ---- Dropdown visibility --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function isOpen(): boolean {
|
|
|
|
|
return dropdownEl?.classList.contains("status-picker-dropdown--open") === true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openDropdown(): void {
|
|
|
|
|
dropdownEl?.classList.add("status-picker-dropdown--open");
|
|
|
|
|
dotEl?.setAttribute("aria-expanded", "true");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeDropdown(): void {
|
|
|
|
|
dropdownEl?.classList.remove("status-picker-dropdown--open");
|
|
|
|
|
dotEl?.setAttribute("aria-expanded", "false");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleDropdown(): void {
|
|
|
|
|
if (isOpen()) {
|
|
|
|
|
closeDropdown();
|
|
|
|
|
} else {
|
|
|
|
|
openDropdown();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Status update --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function applyStatus(status: UserStatus): void {
|
|
|
|
|
currentStatus = status;
|
|
|
|
|
|
|
|
|
|
// Update trigger dot color
|
|
|
|
|
if (dotEl !== null) {
|
|
|
|
|
dotEl.style.background = colorForStatus(status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update checkmarks
|
|
|
|
|
for (const [value, el] of checkEls) {
|
|
|
|
|
el.style.display = value === status ? "" : "none";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Build DOM ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function buildOption(def: StatusDef): HTMLDivElement {
|
|
|
|
|
const row = createElement("div", {
|
|
|
|
|
class: "status-picker-option",
|
|
|
|
|
role: "menuitem",
|
|
|
|
|
tabindex: "0",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const optDot = createElement("span", { class: "status-picker-option-dot" });
|
|
|
|
|
optDot.style.background = def.color;
|
|
|
|
|
|
|
|
|
|
const label = createElement("span", { class: "status-picker-option-label" }, def.label);
|
|
|
|
|
|
|
|
|
|
const check = createElement("span", { class: "status-picker-option-check" });
|
|
|
|
|
check.style.display = def.value === currentStatus ? "" : "none";
|
|
|
|
|
const checkIcon = createIcon("check", 16);
|
|
|
|
|
check.appendChild(checkIcon);
|
|
|
|
|
checkEls.set(def.value, check);
|
|
|
|
|
|
|
|
|
|
appendChildren(row, optDot, label, check);
|
|
|
|
|
|
2026-04-01 11:39:30 +02:00
|
|
|
row.addEventListener(
|
|
|
|
|
"click",
|
|
|
|
|
() => {
|
|
|
|
|
applyStatus(def.value);
|
|
|
|
|
closeDropdown();
|
|
|
|
|
options.onStatusChange(def.value);
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
2026-03-31 19:13:05 +02:00
|
|
|
|
2026-04-01 11:39:30 +02:00
|
|
|
row.addEventListener(
|
|
|
|
|
"keydown",
|
|
|
|
|
(e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
row.click();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
/**
|
|
|
|
|
* The "Set a custom status" row. Only built when a handler was supplied —
|
|
|
|
|
* an input whose value goes nowhere is worse than no input.
|
|
|
|
|
*/
|
|
|
|
|
function buildCustomStatusRow(onChange: (text: string) => void): HTMLDivElement {
|
|
|
|
|
const row = createElement("div", { class: "status-picker-custom" });
|
|
|
|
|
const input = createElement("input", {
|
|
|
|
|
class: "status-picker-custom-input",
|
|
|
|
|
type: "text",
|
|
|
|
|
placeholder: "Set a custom status",
|
|
|
|
|
maxlength: String(MAX_CUSTOM_STATUS_LEN),
|
|
|
|
|
"aria-label": "Custom status",
|
|
|
|
|
"data-testid": "custom-status-input",
|
|
|
|
|
});
|
|
|
|
|
input.value = options.currentCustomStatus ?? "";
|
|
|
|
|
customInputEl = input;
|
|
|
|
|
|
|
|
|
|
const commit = (): void => {
|
|
|
|
|
const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);
|
|
|
|
|
if (text === lastCommittedCustom) return;
|
|
|
|
|
lastCommittedCustom = text;
|
|
|
|
|
input.value = text;
|
|
|
|
|
onChange(text);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
input.addEventListener(
|
|
|
|
|
"keydown",
|
|
|
|
|
(e: KeyboardEvent) => {
|
|
|
|
|
// Keystrokes inside the input must not reach the dropdown's own
|
|
|
|
|
// Enter/Escape handling, which would close the menu mid-edit.
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
commit();
|
|
|
|
|
closeDropdown();
|
|
|
|
|
} else if (e.key === "Escape") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
input.value = lastCommittedCustom;
|
|
|
|
|
closeDropdown();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
|
|
|
|
input.addEventListener("blur", commit, { signal });
|
|
|
|
|
// The row is inside the dropdown; clicking the input must not be treated
|
|
|
|
|
// as picking a status or as an outside click.
|
|
|
|
|
input.addEventListener("click", (e: MouseEvent) => e.stopPropagation(), { signal });
|
|
|
|
|
|
|
|
|
|
row.appendChild(input);
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:13:05 +02:00
|
|
|
// ---- MountableComponent ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function mount(container: Element): void {
|
|
|
|
|
root = createElement("div", { class: "status-picker" });
|
|
|
|
|
|
|
|
|
|
// Trigger dot
|
|
|
|
|
dotEl = createElement("div", {
|
|
|
|
|
class: "status-picker-dot",
|
|
|
|
|
role: "button",
|
|
|
|
|
tabindex: "0",
|
|
|
|
|
"aria-label": "Change status",
|
|
|
|
|
"aria-haspopup": "true",
|
|
|
|
|
"aria-expanded": "false",
|
|
|
|
|
});
|
|
|
|
|
dotEl.style.background = colorForStatus(currentStatus);
|
2026-04-01 11:39:30 +02:00
|
|
|
dotEl.addEventListener(
|
|
|
|
|
"click",
|
|
|
|
|
(e: MouseEvent) => {
|
|
|
|
|
e.stopPropagation();
|
2026-03-31 19:13:05 +02:00
|
|
|
toggleDropdown();
|
2026-04-01 11:39:30 +02:00
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
|
|
|
|
dotEl.addEventListener(
|
|
|
|
|
"keydown",
|
|
|
|
|
(e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
toggleDropdown();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
// Dropdown menu
|
|
|
|
|
dropdownEl = createElement("div", {
|
|
|
|
|
class: "status-picker-dropdown",
|
|
|
|
|
role: "menu",
|
|
|
|
|
});
|
|
|
|
|
for (const def of STATUS_DEFS) {
|
|
|
|
|
dropdownEl.appendChild(buildOption(def));
|
|
|
|
|
}
|
2026-08-01 22:06:14 +02:00
|
|
|
const onCustomStatusChange = options.onCustomStatusChange;
|
|
|
|
|
if (onCustomStatusChange !== undefined) {
|
|
|
|
|
dropdownEl.appendChild(createElement("div", { class: "status-picker-divider" }));
|
|
|
|
|
dropdownEl.appendChild(buildCustomStatusRow(onCustomStatusChange));
|
|
|
|
|
}
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
appendChildren(root, dotEl, dropdownEl);
|
|
|
|
|
container.appendChild(root);
|
|
|
|
|
|
|
|
|
|
// Close on outside click
|
2026-04-01 11:39:30 +02:00
|
|
|
document.addEventListener(
|
|
|
|
|
"click",
|
|
|
|
|
(e: MouseEvent) => {
|
|
|
|
|
if (isOpen() && root !== null && !root.contains(e.target as Node)) {
|
|
|
|
|
closeDropdown();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
2026-03-31 19:13:05 +02:00
|
|
|
|
|
|
|
|
// Close on Escape
|
2026-04-01 11:39:30 +02:00
|
|
|
document.addEventListener(
|
|
|
|
|
"keydown",
|
|
|
|
|
(e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Escape" && isOpen()) {
|
|
|
|
|
closeDropdown();
|
|
|
|
|
dotEl?.focus();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ signal },
|
|
|
|
|
);
|
2026-03-31 19:13:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function destroy(): void {
|
|
|
|
|
ac.abort();
|
|
|
|
|
checkEls = new Map();
|
|
|
|
|
root?.remove();
|
|
|
|
|
root = null;
|
|
|
|
|
dotEl = null;
|
|
|
|
|
dropdownEl = null;
|
2026-08-01 22:06:14 +02:00
|
|
|
customInputEl = null;
|
2026-03-31 19:13:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setStatus(status: UserStatus): void {
|
|
|
|
|
applyStatus(status);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:06:14 +02:00
|
|
|
function setCustomStatus(text: string): void {
|
|
|
|
|
lastCommittedCustom = text;
|
|
|
|
|
if (customInputEl !== null) customInputEl.value = text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { mount, destroy, setStatus, setCustomStatus };
|
2026-03-31 19:13:05 +02:00
|
|
|
}
|